openvibers/openvibe.live Commits
Live keeps no copy of Chat's twelve chat tables: every use removed, boot no longer creates them, the drop is op_003 (after this release is N-1) (#33)
Changes
@@@ -4,7 +4,7 @@Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and the chat-AI summaries through `server/chat/insight-client.js` (Chat's public `/api/chat/ai/*`, cached 60 s), and keeps no copy of them (`test/chat-staged-tables.test.js` guards that nothing under `server/` reads or writes them; Live's `emotes` copy is unread since N+2 and dropped next); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`) once the release before is out of rollback range (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.## Commands@@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:### Data storage--- `data/live.db` — primary SQLite database (users, streams, chat, channel state, AI state).++- `data/live.db` — primary SQLite database (users, streams, channel state, AI state). Chat storage is OpenVibe.Chat's.- `data/live-thumbs` — ephemeral live-stream thumbnails.- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.- `data/media/cache` — song-request (watch-party) downloads.@@@ -75,7 +75,7 @@ The media subsystem lives in **OpenVibe.Media**:## Does not own- **Identity, sessions, follows, blocks, notifications, OpenCoins**: OpenVibe.Network. Live verifies its RS256 tokens offline and follows `network.user.*`, `network.follow.*` and deletion, export and merge events.--- **Chat messages and their tables**: OpenVibe.Chat (since 2026-09-23). Live bridges its WebSocket and moves the remaining staged tables one by one (`server/chat/chat-tables.js`).++- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables Live still held are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`).- **Recordings, clips, thumbnails, files**: OpenVibe.Media. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).- **Pastes and comments**: OpenVibe.Community.- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Only live-stream captions still use Live's whisper.cpp.@@@ -30,5 +30,5 @@"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network","channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default)"],-- "updated": "2026-10-04"++ "updated": "2026-10-05"}@@@ -225,9 +225,8 @@ transaction as the change it describes.| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` || `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |--The chat deploy notice itself is unchanged: stored in `chat_messages` (local chat) or handed to--OpenVibe.Chat over the bridge (`CHAT_AUTHORITY=chat`). Once Chat consumes `live.release.deployed`, the--bridge's `deployNotice` hop can be deleted.++The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and++stores the rolling card (C-84). Live keeps no chat table of its own.Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied oncethrough the SDK inbox (`idempotency_receipts`):@@@ -50,8 +50,9 @@ path are deleted.`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through[server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,-- recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value-- or Live's own table, never a 500.++ recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value,++ never a 500; outside chat mode (dev, drills) the same reads answer null/empty — Live keeps no copy of++ Chat's tables (below).- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`writer and the capture triggers), and this change removed Live's receiver — the`POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its@@@ -59,20 +60,29 @@ path are deleted.read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,the AI context's "first time chatting here" flag reads Chat's first-chat read, and theRobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through-- [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own table only-- while Chat is unreachable. Live's own chat tables stay until a later change drops them; the readers-- that still answer from them are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —-- `dm_*` (no `/api/dm`), the deploy notice, the admin chat log and the leftover dev/rollback fallbacks.--- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,-- `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them-- only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal-- read API (`GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off under-- `LIVE_DRILL`). The dashboard, channel page and upload UI call Chat directly (`/api/chat/channels/:id/…`,-- `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request as an `alert`-- event `{ streamerId, streamId, kind }` on Chat's ingress ([server/monetization/alerts.js](../server/monetization/alerts.js)).-- Live's staged-table machinery (the write relay, dual read and handoff in `chat-tables*.js`), Live's-- chat-AI summary job and the old emote/channel-moderation routes were deleted in the N+1 release; Live's-- copies of the seven supporting tables were dropped in N+2, and the unread `emotes` copy in N+3 (ADR-016).++ [server/chat/chat-reads.js](../server/chat/chat-reads.js); an unreachable Chat answers a cached value,++ else null/empty, never a Live copy.++- **Every chat table is Chat's** (roadmap T3). The staged set — `channel_moderators`,++ `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events` —++ is read only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) (Chat's++ internal read API, `GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off++ under `LIVE_DRILL`); the dashboard, channel page and upload UI call Chat directly++ (`/api/chat/channels/:id/…`, `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on++ Live's request as an `alert` event `{ streamerId, streamId, kind }` on Chat's ingress++ ([server/monetization/alerts.js](../server/monetization/alerts.js)). Live's staged-table machinery, its++ chat-AI summary job and the old emote/channel-moderation routes were deleted in N+1; the seven++ supporting tables were dropped in N+2 and the unread `emotes` copy in N+3 (ADR-016).++- **The twelve chat tables are dropped** (T3 final step, 2026-10-05): `chat_messages`,++ `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`,++ `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`++ and `moderation_actions`. Chat imported them with ids kept at the cutover and has been their only++ writer since; Live's readers and writers are gone (#28–#32 and this release), and it no longer creates++ them. The drop itself is the operator migration `op_003_drop_chat_tables` via++ [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js) (dry run, then `--apply`: backup, drop),++ run once the release before is out of rollback range (ADR-028) — see++ [docs/cutover-chat-tables-003.md](cutover-chat-tables-003.md). The deployment notice is Chat's too: Live++ queues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js`++ guards that nothing under `server/` names them.## Features@@@ -193,8 +203,8 @@ each chat line is translated a beat after it is broadcast and delivered to the sDirection rules: any non-English message → English; an English message in a non-Englishchannel → the channel's language (so a Japanese streamer reads his chat without anyone typingJapanese). Emote-only / command / link-only lines are skipped. Translations are cached by--content hash in the `translations` table and persisted into `chat_messages.metadata.translation`,--so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight;++content hash in the `translations` table and persisted by Chat into the message's++`metadata.translation`, so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight;overflow lines simply stay untranslated).**Any line, your language.** Every chat message also has a hover 🌐 button that asks `POST /api/i18n/translate { text, to }` for the line in the viewer's browser language (rate-limited per IP: 15/min anonymous, 40/min signed in; cached like the automatic translations). This is the third direction — a Korean viewer reading a Japanese streamer, a Japanese viewer on an English stream — so nobody is left out, English users included.nnew file mode 100644@@@ -0,0 +1,70 @@++# Cutover: drop Live's copies of OpenVibe.Chat's chat tables (T3 final step, operator migration)++++This change removes Live's last references to the twelve chat tables OpenVibe.Chat owns —++`chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`,++`tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`,++`stream_first_chats` and `moderation_actions` — stops creating them, and adds the operator migration++**`op_003_drop_chat_tables`** ([server/db/migrations.js](../server/db/migrations.js)) plus the script++that runs it, [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js).++++Chat imported these tables with ids kept at the 2026-09-23 cutover and has been their only writer++since; Live's readers and writers are gone (PRs #28–#32 and the release that carries this migration).++Unlike the boot migrations, `op_003` is **not** applied on boot: the release before this one still runs++SQL over the tables (test/n-1.test.js), so they may only be dropped once that release is out of++rollback range (ADR-028, `test/fixtures/destructive-migrations.json`). The tables are Live's own++SQLite tables in `live.db`, not the shared PostgreSQL.++++## Order++++1. Confirm this release is the running one and has been through the rollback window:++ `ov access run openvibe-ovh health live` answers `ready` and the release list shows it as current++ (the previous release no longer in rollback range).++2. From the release directory as the service user, dry run: `node scripts/chat-tables-drop.js` — it++ prints which of the twelve tables exist and how many rows each holds; changes nothing.++3. `node scripts/chat-tables-drop.js --apply` — writes++ `<database dir>/backups/live-pre-chat-tables-drop-<time>.db` (mode 0600) and runs `op_003`, which++ drops the twelve tables and their indexes. The ledger outcome is printed (`applied`, or `already`++ on a repeat).++++## Backup++++- The script takes its own `live.db` backup before it drops anything and prints the path. Restoring++ that file is the way back to a database that still holds the rows.++- The host's wider snapshots (`ov access run openvibe-ovh db-backup`) are an alternative. The rows++ themselves live in OpenVibe.Chat, which imported them with ids kept — the backup is the operator's++ safety net, not their home.++++## How to check success++++- **Drop applied.** `--apply` prints `op_003_drop_chat_tables: applied`, and the read-only check on the++ host prints nothing for the tables and `applied` for the ledger row:++++```sh++sqlite3 -readonly <Live's data dir>/live.db \++ "SELECT name FROM sqlite_master WHERE name IN ('chat_messages','dm_conversations','dm_participants','dm_messages','dm_blocks','tts_voice_overrides','channel_sounds','relay_users','hidden_relay_users','pending_ip_messages','stream_first_chats','moderation_actions')" # → nothing++sqlite3 -readonly <Live's data dir>/live.db \++ "SELECT id, mode FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'" # → op_003_…|applied++```++++- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; a restart prints no++ `no such table` for any of the twelve, home stats and the admin chat console still answer (from++ Chat), and a chat line still reaches the watch page.++- **Idempotent.** Rerunning `--apply` prints `already` and drops nothing further.++++## Rollback / restore++++Dropping the tables is destructive and a code rollback does not bring them back — the previous release++runs fine without them (it reads them only on the paths production does not take). Restore only if the++backup turns out to be needed: roll back the release (`ov access run openvibe-ovh rollback live`), stop++Live, copy the script's backup (or the host `db-backup`) `live.db` over Live's database as the service++user with the original owner and mode and no stale `-wal`/`-shm` beside it, and start Live. Restoring++also discards every other Live write made since the backup, so prefer leaving the tables dropped.++++## Rehearsal++++[test/chat-tables-dropped.test.js](../test/chat-tables-dropped.test.js) checks the static guard++(nothing under `server/` names any of the twelve, the drop migration's own statements excepted), that a++fresh schema creates none of them, that a boot neither creates nor drops them, and that `op_003`++drops legacy copies and is idempotent on a second run. `test/n-1.test.js` proves the previous release++still prepares every statement it runs on a database this release migrated (which is why the drop must++not run at boot).ddeleted file mode 100644@@@ -1,52 +0,0 @@--#!/usr/bin/env node--'use strict';--/**-- * One-time backfill (WS-D task 1): Live's moderation actions logged before live.moderation.action-- * existed go to OpenVibe.Network's moderation audit log, each with its original time. Same rule as-- * logModerationAction (server/db/database.js): tidying one's own messages, configuring one's own channel-- * and acting on oneself are not sent. Idempotent: moderation_events_backfill remembers every row sent,-- * and --before (required) keeps it off rows the live code already announced.-- *-- * Run on the host AS LIVE, with Live's environment (EVENTS_URL, the OAuth client secret), e.g.:-- * node scripts/backfill-moderation-events.js --before '2026-09-25 20:31:27' [--dry-run]-- * It queues into Live's outbox and relays until the queue is empty. Nothing secret is printed.-- */--const args = process.argv.slice(2);--const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };--const before = flag('--before');--const dryRun = args.includes('--dry-run');--if (!before || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before)) {-- console.error("--before 'YYYY-MM-DD HH:MM:SS' (UTC, the deploy time of logModerationAction's event) is required");-- process.exit(2);--}----(async () => {-- const db = require('../server/db/database');-- db.initDb();-- const d = db.getDb();-- d.exec('CREATE TABLE IF NOT EXISTS moderation_events_backfill (action_id INTEGER PRIMARY KEY, sent_at DATETIME DEFAULT CURRENT_TIMESTAMP)');-- const rows = d.prepare(`SELECT m.* FROM moderation_actions m LEFT JOIN moderation_events_backfill b ON b.action_id = m.id-- WHERE b.action_id IS NULL AND m.created_at < ? ORDER BY m.id`).all(before);-- console.log(`${rows.length} action(s) logged before ${before} not yet backfilled`);-- if (dryRun) { process.exit(0); }-- const streamEvents = require('../server/events/stream-events');-- const outbox = streamEvents.init({ intervalMs: 500 });-- if (!outbox) { console.error('the outbox is off (EVENTS_URL or the client secret is missing)'); process.exit(1); }-- let sent = 0, skipped = 0;-- for (const r of rows) {-- let details = {};-- try { details = JSON.parse(r.details || '{}'); } catch { /* keep {} */ }-- const at = new Date(`${String(r.created_at).replace(' ', 'T')}Z`);-- d.transaction(() => {-- const ok = db.announceModerationAction(r.id, { ...r, details }, { at: Number.isNaN(at.getTime()) ? null : at.toISOString() });-- d.prepare('INSERT INTO moderation_events_backfill (action_id) VALUES (?)').run(r.id);-- if (ok) sent++; else skipped++;-- })();-- }-- console.log(`queued ${sent}, not moderation ${skipped}; relaying…`);-- for (let i = 0; i < 60 && streamEvents.status().pending > 0; i++) { await outbox.flush(); await new Promise((res) => setTimeout(res, 500)); }-- const st = streamEvents.status();-- console.log(`pending ${st.pending}, rejected ${st.rejected}${st.last_error ? ` (last error: ${st.last_error})` : ''}`);-- outbox.stop && outbox.stop();-- process.exit(st.rejected ? 1 : 0);--})().catch((err) => { console.error(err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,97 @@++#!/usr/bin/env node++/**++ * The contract step for Live's copies of OpenVibe.Chat's chat tables (roadmap T3, final step): Live keeps no++ * copy of chat_messages, the DM tables, or the moderation/relay/sound/TTS queues any more (the readers and++ * writers went in #28–#32 and this release no longer creates them), but the release before this one still++ * runs SQL over them (test/n-1.test.js), so the drop is operator-run. Run it once that release is out of++ * rollback range.++ *++ * node scripts/chat-tables-drop.js [--db <live.db>]++ * # dry run (the default): which of the twelve tables exist, with row counts; changes nothing++ * node scripts/chat-tables-drop.js --apply [--backup <file.db>] [--db <live.db>]++ * # an online backup first (default <database dir>/backups/live-pre-chat-tables-drop-<time>.db, mode++ * # 0600), then server/db/migrations.js operator migration op_003_drop_chat_tables, which drops the++ * # twelve tables and their indexes. Idempotent: a second run reports 'already'.++ *++ * The rows in them came from OpenVibe.Chat (its scripts/import-from-live.js imported them with ids kept at++ * the 2026-09-23 cutover, and Chat has been their only writer since) and Live has read none of them since++ * #32; the backup is the operator's safety net, not a second home for the data.++ * Default database: $DB_PATH, else data/live.db under $DATA_DIR or the working directory (server/paths.js).++ * In production run it from /opt/openvibe.live/current as the service user (data -> ../../shared/data).++ */++'use strict';++const fs = require('fs');++const path = require('path');++++const ID = 'op_003_drop_chat_tables';++const TABLES = [++ 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',++ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',++ 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',++];++++function parseArgs(argv) {++ const opts = { mode: 'dry', db: null, backup: null, help: false };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ const next = () => {++ const v = argv[++i];++ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);++ return v;++ };++ if (a === '--apply') opts.mode = 'apply';++ else if (a === '--dry-run') opts.mode = 'dry';++ else if (a === '--db') opts.db = next();++ else if (a === '--backup') opts.backup = next();++ else if (a === '--help' || a === '-h') opts.help = true;++ else throw new Error(`unknown option ${a}`);++ }++ return opts;++}++++/** Which of the twelve exist, and how many rows each holds. Reads only. */++function counts(db) {++ const present = [];++ for (const t of TABLES) {++ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t)) continue;++ present.push({ table: t, rows: db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n });++ }++ return present;++}++++function report(present, log) {++ if (!present.length) { log('none of the twelve chat tables is present; the drop is already applied (or these are fresh-schema databases)'); return; }++ log(`${present.length} of ${TABLES.length} chat table(s) present:`);++ for (const p of present) log(` ${p.table}: ${p.rows} row(s)`);++}++++async function backup(db, file) {++ fs.mkdirSync(path.dirname(file), { recursive: true });++ await db.backup(file);++ fs.chmodSync(file, 0o600);++ return file;++}++++async function main(argv = process.argv.slice(2), log = console.log) {++ const opts = parseArgs(argv);++ if (opts.help) { log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); return 0; }++ const Database = require('better-sqlite3');++ const DB_PATH = path.resolve(opts.db || require('../server/paths').dbPath());++ const db = new Database(DB_PATH, { readonly: opts.mode === 'dry', fileMustExist: true });++ try {++ report(counts(db), log);++ if (opts.mode === 'dry') return 0;++ const file = opts.backup || path.join(path.dirname(DB_PATH), 'backups', `live-pre-chat-tables-drop-${new Date().toISOString().replace(/[:.]/g, '-')}.db`);++ log(`backup: ${await backup(db, file)}`);++ const res = require('../server/db/migrations').runOperator(db, ID);++ log(`${ID}: ${res.outcome}${res.error ? ` (${res.error})` : ''}`);++ if (res.outcome !== 'failed') report(counts(db), log);++ return res.outcome === 'failed' ? 1 : 0;++ } finally { db.close(); }++}++++if (require.main === module) {++ main().then((code) => process.exit(code), (err) => { console.error(err.message); process.exit(1); });++}++++module.exports = { ID, TABLES, counts, parseArgs, main };@@@ -404,23 +404,8 @@ router.post('/delete-message', async (req, res) => {if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });}-- // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached.-- if (delivery.ingress()) {-- await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });-- }-- else db.deleteChatMessage(parseInt(message_id), req.user.id);---- // Broadcast deletion to connected clients — reach the streamer's whole-- // channel room (all slots + offline viewers), plus the global feed.-- 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) {-- delivery.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);-- if (message.stream_id) delivery.forwardToGlobal(message.stream_id, delPayload);-- else delivery.broadcastGlobal(delPayload);-- } else {-- delivery.broadcastGlobal(delPayload);-- }++ // Chat deletes the row and broadcasts the delete to every surface it reached.++ await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });delivery.logModeration({scope_type: message.stream_id ? 'stream' : 'site',@@@ -458,34 +443,12 @@ router.post('/delete-user-messages', async (req, res) => {}}-- let ids = [];-- if (delivery.ingress()) {-- // Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.-- const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]-- : anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];-- const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };-- const r = await delivery.moderate(action, { ...subject, ...scoped });-- ids = (r && Array.isArray(r.ids)) ? r.ids : [];-- } else if (user_id) {-- ids = db.deleteUserChatMessages(parseInt(user_id), { streamId: scopedStreamId, deletedBy: req.user.id });-- } else if (anon_id) {-- ids = db.deleteAnonChatMessages(anon_id, { streamId: scopedStreamId, deletedBy: req.user.id });-- } else if (relay_username) {-- ids = db.deleteRelayUserMessages(relay_username, { streamId: scopedStreamId, deletedBy: req.user.id });-- }---- // Broadcast deletions to connected clients-- if (ids.length > 0 && !delivery.ingress()) {-- const deleteMsg = { type: 'delete-messages', ids };-- if (scopedStreamId) {-- const chanUid = db.getStreamById(scopedStreamId)?.user_id || null;-- delivery.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);-- delivery.forwardToGlobal(scopedStreamId, deleteMsg);-- } else {-- // Global deletion — Chat fans it out to the clients it holds; Live has none.-- delivery.broadcastGlobal(deleteMsg);-- }-- }++ // Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.++ const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]++ : anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];++ const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };++ const r = await delivery.moderate(action, { ...subject, ...scoped });++ const ids = (r && Array.isArray(r.ids)) ? r.ids : [];const target = user_id ? `user ${user_id}` : anon_id ? `anon ${anon_id}` : `relay ${relay_username}`;delivery.logModeration({@@@ -531,10 +494,7 @@ router.post('/relay-user/hide', async (req, res) => {// Unban / unhide — remove the ban for this exact relay identity only.if (action === 'unban' || action === 'unhide') {-- if (delivery.ingress()) {-- await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });-- }-- else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);++ await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });invalidateRelayHidden(channel_id || null);delivery.logModeration({scope_type: channel_id ? 'channel' : 'site',@@@ -546,16 +506,7 @@ router.post('/relay-user/hide', async (req, res) => {return res.json({ message: `[${platform}] ${external_username} unbanned` });}-- if (delivery.ingress()) {-- await delivery.moderate('relay-hide', { channel_id: channel_id || undefined, platform, external_username, mode: action || 'hide', reason: reason || undefined, created_by: req.user.id });-- } else db.hideRelayUser({-- channelId: channel_id || null,-- platform,-- externalUsername: external_username,-- action: action || 'hide',-- reason,-- createdBy: req.user.id,-- });++ await delivery.moderate('relay-hide', { channel_id: channel_id || undefined, platform, external_username, mode: action || 'hide', reason: reason || undefined, created_by: req.user.id });invalidateRelayHidden(channel_id || null);delivery.logModeration({@@@ -582,8 +533,7 @@ router.delete('/relay-user/:id', async (req, res) => {const allowed = permissions.isGlobalModOrAbove(req.user)|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });-- if (delivery.ingress()) { await delivery.moderate('relay-unhide', { id: row.id }); }-- else db.unhideRelayUser(row.id);++ await delivery.moderate('relay-unhide', { id: row.id });invalidateRelayHidden(row.channel_id);delivery.logModeration({@@@ -660,39 +610,11 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {return res.status(403).json({ error: 'Access denied' });}-- // The held rows this IP is about — from Chat when Chat is the authority (Live holds no-- // copy), else Live's own queue. In Chat mode they are not re-broadcast here: Chat releases-- // them and broadcasts; Live only needs the count for its answer and log.-- const pendingMsgs = delivery.ingress()-- ? (await chatReads.pendingIp(channelId, { limit: 500 })).filter((m) => m.ip_address === ip)-- : db.all("SELECT * FROM pending_ip_messages WHERE channel_id = ? AND ip_address = ? AND status = 'pending'", [channelId, ip]);++ // The held rows this IP is about — Chat holds them; Live keeps no queue. Live only needs++ // the count for its answer and log: Chat releases them and broadcasts.++ const pendingMsgs = (await chatReads.pendingIp(channelId, { limit: 500 })).filter((m) => m.ip_address === ip);-- // Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).-- if (delivery.ingress()) {-- await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });-- }-- else db.approveAllFromIp(channelId, ip, req.user.id);---- // Broadcast previously-held messages as real chat messages-- for (const msg of delivery.ingress() ? [] : pendingMsgs) {-- if (msg.stream_id) {-- const chatMsg = {-- type: 'chat',-- username: msg.username,-- user_id: msg.user_id || null,-- anon_id: msg.anon_id || null,-- role: msg.user_id ? 'user' : 'anon',-- message: msg.message,-- stream_id: msg.stream_id,-- is_global: false,-- profile_color: '#999',-- timestamp: msg.created_at,-- was_held: true,-- };-- delivery.broadcastToStream(msg.stream_id, chatMsg);-- delivery.forwardToGlobal(msg.stream_id, chatMsg);-- }-- }++ await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });delivery.logModeration({scope_type: 'channel',@@@ -723,10 +645,7 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {return res.status(403).json({ error: 'Access denied' });}-- if (delivery.ingress()) {-- await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });-- }-- else db.denyAllFromIp(channelId, ip, req.user.id);++ await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });delivery.logModeration({scope_type: 'channel',@@@ -758,13 +677,7 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {return res.status(403).json({ error: 'Access denied' });}-- if (delivery.ingress()) {-- await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });-- } else db.reviewPendingIpMessage(parseInt(message_id), {-- status,-- reviewedBy: req.user.id,-- channelId,-- });++ await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });res.json({ message: `Message ${status}` });} catch (err) {@@@ -963,8 +876,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {try {const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);if (req.body?.reset) {-- if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey }); }-- else db.deleteTtsVoiceOverride(identityKey);++ delivery.moderate('tts-voice-override', { identity_key: identityKey });return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });}let params;@@@ -981,8 +893,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };}const clamped = ttsEngine.clampVoiceParams(params);-- if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }-- else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);++ delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id });res.json({ isOverride: true, params: clamped });} catch (err) {console.error('[Mod] tts-voice set error:', err.message);@@@ -231,15 +231,8 @@ router.put('/users/:id', (req, res) => {db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);}-- // If display_name or username changed, update denormalized chat_messages.username-- // (chat_messages.username stores display_name at message creation time)-- if (display_name || username) {-- const freshUser = db.getUserById(req.params.id);-- if (freshUser) {-- const newChatName = freshUser.display_name || freshUser.username;-- db.run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, req.params.id]);-- }-- }++ // No chat-name update here: OpenVibe.Chat owns the chat rows and keeps stored message++ // names current via ctx_users; the delivery.invalidate() below tells it this user changed.const user = db.getUserById(req.params.id);// Sanitize — never expose password_hash, email (legacy columns, WS-B task 2) or stream_key@@@ -137,7 +137,7 @@ async function _momentContext(streamId, vodId) {const clipTimes = await require('../media-proxy/lookups').clipStartTimes(streamId, vodId);// Chat's own buckets since the stream began (offsets are relative to started_at, as before).const started = (() => { try { const r = db.get('SELECT started_at FROM streams WHERE id = ?', [streamId]); return r && r.started_at ? Date.parse(String(r.started_at).replace(' ', 'T') + 'Z') : 0; } catch { return 0; } })();-- const spikes = started > 0 ? (await chatReads.spikeOffsets(streamId, 30, 8, started) || []) : (db.getChatSpikeOffsets(streamId, 30, 8) || []);++ const spikes = started > 0 ? (await chatReads.spikeOffsets(streamId, 30, 8, started) || []) : [];// Non-speech sounds are strong moment candidates — an explosion or a burst of// laughter marks a highlight as reliably as anything said out loud.let sounds = [];@@@ -5,7 +5,7 @@* stream_timeline_events (live transcript + sounds) stream_memories (frames)* streams.ai_overview / ai_title streamer_overviews* app_state ai_whole_overview_<uid> (combined blurb)-- * chat_messages (channel + relayed + bot lines) follows / subscriptions / stream_first_chats++ * chat (channel + relayed + bot lines, via Chat) follows / subscriptions / first-chat state* channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)* ai_viewer_threads (open conversations)*@@@ -205,8 +205,7 @@ function personBlock(line, channelUserId, settings) {}// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat-- // read through a sync peek; a cold cache or a Chat outage answers "not first" and never-- // Live's own frozen copy.++ // read through a sync peek; a cold cache or a Chat outage answers "not first".const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');} catch { /* */ }@@@ -45,54 +45,17 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})const color = botColor(bot);const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;const delivery = require('../../chat/chat-delivery');-- if (delivery.ingress()) {-- // Chat persists, broadcasts, mirrors to global and reads it aloud in one call; the real id arrives later.-- delivery.after(delivery.message({-- stream_id: streamId, channel_user_id: worker.userId || undefined, username: bot.username, message,-- message_type: 'chat', is_global: false, reply_to_id: replyToId || undefined, source_platform: 'ai',-- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },-- mirror: true, frame: { role: 'user', profile_color: color, is_ai: true, filtered: false },-- tts: ttsOn ? { identity_key: `aibot:${bot.username.toLowerCase()}` } : undefined,-- }), (id) => forwardToPowerChat(worker, bot, message, id));-- try { db.touchChannelAiBot(bot.id); } catch { /* */ }-- return null;-- }-- let id = null;-- try {-- const res = db.saveChatMessage({-- stream_id: streamId,-- channel_user_id: worker.userId,-- user_id: null,-- username: bot.username,-- message,-- message_type: 'chat',-- is_global: false,-- reply_to_id: replyToId || null,-- source_platform: 'ai', // marker → excluded from chat-AI feeds-- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },-- });-- id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;-- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }---- const chatMsg = {-- type: 'chat', id: id || undefined,-- username: bot.username, core_username: null,-- user_id: null, anon_id: null, role: 'user',-- message, stream_id: streamId, is_global: false,-- reply_to_id: replyToId || null,-- avatar_url: null, profile_color: color,-- is_ai: true, source_platform: 'ai',-- filtered: false, timestamp: new Date().toISOString(),-- };-- try {-- 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);++ // Chat persists, broadcasts, mirrors to global and reads it aloud in one call; the real id++ // arrives later. Live keeps no chat table, so there is no local id to return.++ delivery.after(delivery.message({++ stream_id: streamId, channel_user_id: worker.userId || undefined, username: bot.username, message,++ message_type: 'chat', is_global: false, reply_to_id: replyToId || undefined, source_platform: 'ai',++ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },++ mirror: true, frame: { role: 'user', profile_color: color, is_ai: true, filtered: false },++ tts: ttsOn ? { identity_key: `aibot:${bot.username.toLowerCase()}` } : undefined,++ }), (id) => forwardToPowerChat(worker, bot, message, id));try { db.touchChannelAiBot(bot.id); } catch { /* */ }-- return id;++ return null;}function forwardToPowerChat(worker, bot, message, id) {@@@ -21,9 +21,9 @@* kept rows point at nobody.** Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and-- * Community own and erase themselves. The five chat tables (channel_moderators, channel_moderation_settings,-- * user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3) and erased by its eraser-- * (Chat server/chat/account-data.js), never here.++ * Community own and erase themselves, and every OpenVibe.Chat table (chat_messages, the moderation, DM, sound,++ * relay, TTS overrides and first-chat tables of roadmap T3): Chat's eraser (Chat server/chat/account-data.js)++ * deletes the person's rows there, never this one. Live keeps no copy of any of them since 2026-10-05.** Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.*/@@@ -35,8 +35,8 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;const FROZEN = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments']);const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'moderation_actions', 'bans','token_revocations', 'verification_keys', 'subject_merges', 'account_data_events']);--// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: their own chat lines,--// sign-in IPs and VPN requests.++// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs, VPN++// requests, and their lines in the legacy chat_messages copy while it still exists (see LEGACY_CHAT_COPIES).const OVERRIDE_DELETE = new Set(['chat_messages.user_id', 'ip_log.user_id', 'vpn_approvals.user_id']);// Copies of the person's name kept beside a person column: cleared wherever the row stays.const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];@@@ -45,8 +45,13 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;const ROW_LIMIT = 2000;const PART_BUDGET = 18 * 1024 * 1024;--// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.++// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them (dropped in N+2/N+3).const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);++// Live's legacy copies of the twelve OpenVibe.Chat tables (a read mirror until 2026-10-05; dropped by++// op_003_drop_chat_tables). Never exported (Chat contributes the chat part), but while a copy still exists here a++// deletion erases the person from it exactly as before, so a stale copy never outlives their erasure.++const LEGACY_CHAT_COPIES = new Set(['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',++ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions']);function ensureSchema(d) {d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (@@@ -104,6 +109,7 @@ function exportPart(d, userId) {bytes += JSON.stringify(profile).length;const other = {};for (const [t, cols] of personColumns(d)) {++ if (CHAT_TABLES.has(t) || LEGACY_CHAT_COPIES.has(t)) continue; // Chat's rows: Chat contributes them to the chat part of the exportconst keep = d.prepare(`PRAGMA table_info(${q(t)})`).all().map((c) => c.name).filter((c) => !SECRET_COL.test(c));if (!keep.length) continue;const where = cols.map((c) => `${q(c.col)} = ?`).join(' OR ');@@@ -11,7 +11,8 @@* 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); the read mirror Chat used to write back was-- * retired on 2026-10-05 and Live keeps its chat tables only until a later change drops them;++ * retired on 2026-10-05, Live keeps no copy of Chat's tables, and its twelve are dropped by the++ * operator migration op_003_drop_chat_tables (scripts/chat-tables-drop.js);* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.* 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.@@@ -13,7 +13,7 @@* 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 | 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)++ * logModeration(entry) a moderation-log row (Chat's moderation_actions shape)* disconnect({ userId, ip, streamId })* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })* after(value, fn) fn(value) now for a plain value, after it resolves for a promise@@@ -78,8 +78,9 @@ function moderate(action, fields, { key } = {}) {}const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];++// Chat owns the moderation log (its moderation_actions; Live keeps no copy), so this always goes++// through Chat's ingress — in every mode, like every other moderation write.function logModeration(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);@@@ -4,13 +4,13 @@* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,* pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's-- * internal read API (server/chat/chat-client.js). Chat's read mirror was retired on 2026-10-05-- * (Chat #25 removed the sender, Live dropped the receiver), so Live's own copies of the tables Chat-- * owns are frozen and NO remote answer falls back to them.++ * internal read API (server/chat/chat-client.js). Live keeps no copy of any of them since 2026-10-05++ * (the read mirror was retired on both sides; the tables themselves are dropped by migration++ * op_003_drop_chat_tables), so outside chat mode every one of these answers null / empty.** Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the-- * same call answers from Live's own tables — the caller never has to know which. Unsetting-- * CHAT_AUTHORITY is not a rollback lever: Live runs no chat server any more either way.++ * same call answers null / empty — the caller never has to know which. Unsetting CHAT_AUTHORITY is++ * not a rollback lever: Live runs no chat server any more either way.** Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek@@@ -41,10 +41,9 @@ let lastLog = null;function drill() { try { return require('../drill').enabled; } catch { return false; } }function remote() { return isRemote() && !drill(); }function local() { return require('../db/database'); }--/** Run a local (Live-table) read for a synchronous caller, answering `dflt` if it throws. */++/** Run a Live-table read for a synchronous caller, answering `dflt` if it throws. Still used for++ * the non-chat home metrics (users, follows, …), which Live owns. */function safeLocal(fn, dflt) { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } }--/** Epoch ms → the UTC 'YYYY-MM-DD HH:MM:SS' text chat_messages.timestamp stores. */--const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');function note(what, err) {const m = `Chat read ${what}: ${(err && err.message) || err}`;@@@ -94,9 +93,9 @@ function rememberFailure(key) {}/**-- * Async answer: Chat (remote) or Live's own tables (local), cached. A failure answers the last-- * good value, else null; with `strict` it throws `Chat unavailable` instead (the moderation-- * console). `ttl: 0` always reads live and never caches. An error carrying a `status` (a++ * Async answer: Chat when it is the authority, else the (empty) local answer, cached. A failure++ * answers the last good value, else null; with `strict` it throws `Chat unavailable` instead (the++ * moderation console). `ttl: 0` always reads live and never caches. An error carrying a `status` (a* deliberate "not supported") is handed to the caller as it is.*/function load(key, remoteFn, localFn, { ttl = CACHE_TTL_MS, strict = false, ttlFor = null } = {}) {@@@ -144,13 +143,9 @@ function peek(key, remoteFn, localFn, opts) {}// ── Stats ─────────────────────────────────────────────────────────────────────--function localSiteStats() {-- const db = local();-- const r = db.get(`SELECT COUNT(*) AS messages,-- COUNT(DISTINCT COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)) AS chatters-- FROM chat_messages WHERE is_deleted = 0`) || {};-- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0 };--}++// Live keeps no chat_messages: outside chat mode these reads answer zero/empty. The named local*++// functions stay so load()/peek() keep one shape and the answers are explicit, not absent.++function localSiteStats() { return { messages: 0, chatters: 0 }; }async function remoteSiteStats() {const out = await client.readStats({ kind: 'site' });if (!out) throw unavailable('site stats');@@@ -158,16 +153,8 @@ async function remoteSiteStats() {}/** { messages, chatters } across the whole site. */function siteStats() { return load('site', remoteSiteStats, localSiteStats); }--/**-- * The home-stats snapshot answers with `messages` only, so its local (rollback / dev) answer is a-- * plain COUNT(*): the distinct-chatter scan `siteStats()` runs is a full scan this synchronous peek-- * would otherwise force on every home-stats compute. In chat mode it answers Chat's cached value.-- */--function localSiteStatsMessages() {-- const db = local();-- const r = db.get('SELECT COUNT(*) AS messages FROM chat_messages WHERE is_deleted = 0') || {};-- return { messages: Number(r.messages) || 0 };--}++/** The home-stats snapshot's shape: outside chat mode there is no count to answer. */++function localSiteStatsMessages() { return { messages: 0 }; }function siteStatsPeek() { return peek('site', remoteSiteStats, localSiteStatsMessages); }// ── Home daily series (the hero charts) ──────────────────────────────────────@@@ -202,7 +189,11 @@ async function remoteHomeSeries(metric, days) {return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map((p) => p.value)), before: pick(beforeOut), prev_total: pick(prevOut) };}--/** The daily series behind a hero stat, from Chat (remote) or Live's own tables (local), cached. */++/**++ * The daily series behind a hero stat, from Chat (remote) or Live's own tables (local), cached.++ * Only the two chat metrics come through here; outside chat mode they answer null (Live keeps no++ * chat_messages — homeSeriesLocal has no entry for them).++ */function homeSeries(metric, days) {const d = clampSeriesDays(days);const key = `hs:${metric}:${d}:${utcDayStart(Date.now())}`;@@@ -210,9 +201,9 @@ function homeSeries(metric, days) {return load(key, () => remoteHomeSeries(metric, d), localFn).then((v) => v || safeLocal(localFn, null));}/**-- * The same for the synchronous home-stats series route: the last good answer (or, on a cold cache,-- * Live's mirror-filled tables) while Chat refreshes in the background — never zeros just because-- * Chat has not answered yet.++ * The same for the synchronous home-stats series route: the last good answer while Chat refreshes++ * in the background — never zeros just because Chat has not answered yet. Outside chat mode there++ * is nothing to answer from: null.*/function homeSeriesPeek(metric, days) {const d = clampSeriesDays(days);@@@ -220,8 +211,8 @@ function homeSeriesPeek(metric, days) {const localFn = () => local().homeSeriesLocal(metric, d);const v = peek(key, () => remoteHomeSeries(metric, d), localFn);if (v) return v;-- if (!remote()) return null; // the peek already answered from Live's own tables-- return safeLocal(localFn, null); // cold / Chat unreachable: Live's mirror-filled tables++ if (!remote()) return null; // outside chat mode Live keeps no chat series++ return safeLocal(localFn, null); // cold / Chat unreachable}/** The busiest chatters of a room (or the site, when neither stream nor channel is given), newest window first. */@@@ -231,41 +222,16 @@ function topChatters({ since, streamId, channelUserId, limit = 10 } = {}) {const out = await client.readStats({ kind: 'channel-top', since, stream_id: streamId, channel_user_id: channelUserId, limit });if (!out || !Array.isArray(out.top_chatters)) throw unavailable('top chatters');return out.top_chatters;-- }, () => {-- const db = local();-- const where = ['c.is_deleted = 0', "c.message_type <> 'system'", 'COALESCE(u.is_banned, 0) = 0'];-- const params = [];-- if (since) { where.push('c.timestamp >= ?'); params.push(new Date(since).toISOString().slice(0, 19).replace('T', ' ')); }-- if (streamId) { where.push('c.stream_id = ?'); params.push(streamId); }-- if (channelUserId) { where.push('c.channel_user_id = ?'); params.push(channelUserId); }-- params.push(limit);-- return db.all(`SELECT c.user_id, MAX(c.username) AS username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS count-- FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id-- WHERE ${where.join(' AND ')}-- GROUP BY c.user_id, CASE WHEN c.user_id IS NULL THEN c.username END-- ORDER BY count DESC LIMIT ?`, params) || [];-- });++ }, () => []);}// ── Stats over a window / one room ───────────────────────────────────────────// Chat's stats read takes since/until plus at most one of stream_id / channel_user_id and answers// { messages, chatters } for that window (kind 'stream' also answers the soundboard count). The// home-stats snapshot and the digest ask site-wide, the recap and stream analytics ask per stream,--// the star picker per channel. The local (rollback / dev) answer is the same SQL Live's own chat--// runs; a peek answers the last good value while Chat refreshes in the background.--function localWindowStats({ since, until, streamId, channelUserId } = {}) {-- const db = local();-- const where = ['is_deleted = 0'];-- const params = [];-- if (since != null) { where.push('timestamp >= ?'); params.push(sqlTime(since)); }-- if (until != null) { where.push('timestamp < ?'); params.push(sqlTime(until)); }-- if (streamId != null) { where.push('stream_id = ?'); params.push(Number(streamId)); }-- if (channelUserId != null) { where.push('channel_user_id = ?'); params.push(Number(channelUserId)); }-- const r = db.get(`SELECT COUNT(*) AS messages,-- COUNT(DISTINCT COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)) AS chatters-- FROM chat_messages WHERE ${where.join(' AND ')}`, params) || {};-- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0 };--}++// the star picker per channel. Outside chat mode the answer is zero (Live keeps no chat table); a++// peek answers the last good value while Chat refreshes in the background.++function localWindowStats() { return { messages: 0, chatters: 0 }; }async function remoteWindowStats({ since, until, streamId, channelUserId } = {}) {const out = await client.readStats({ kind: 'site', since, until, stream_id: streamId, channel_user_id: channelUserId });if (!out) throw unavailable('window stats');@@@ -276,7 +242,7 @@ const HOUR_BUCKET_MS = 60 * 60_000; // a 7/14/30-day window snaps to the/*** Snap a window's since/until to a stable bucket BEFORE both the cache key and the request use them.* Callers build these from Date.now(), which changes on every compute, so an exact key is cold on-- * every call: Chat is never actually consulted (the peek answers a mirror scan instead), each call++ * every call: Chat is never actually consulted (the peek answers a stale value instead), each call* mints a new cache entry, and the request it sends is thrown away. A minute — or an hour once the* window spans a day or more — is far finer than these coarse counts need and keeps the key stable* well past CACHE_TTL_MS. The request is sent with the bucketed values so it matches its own key.@@@ -296,7 +262,7 @@ function windowStats(o = {}) {}/*** The same, for a caller that cannot await (the home-stats snapshot, the digest). Answers the last-- * good value, else null — never a synchronous mirror scan, so the hero never presents Live's stale++ * good value, else null — never a synchronous local scan, so the hero never presents a local* count as Chat's; the caller decides what an unknown window means.*/function windowStatsPeek(o = {}) {@@@ -304,14 +270,7 @@ function windowStatsPeek(o = {}) {return peek(windowKey(b), () => remoteWindowStats(b), () => localWindowStats(b));}--function localStreamStats(streamId) {-- const db = local();-- const r = db.get(`SELECT COUNT(*) AS messages,-- COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS chatters,-- SUM(CASE WHEN message_type = 'soundboard' THEN 1 ELSE 0 END) AS sounds-- FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0`, [Number(streamId)]) || {};-- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0, sounds: Number(r.sounds) || 0 };--}++function localStreamStats() { return { messages: 0, chatters: 0, sounds: 0 }; }async function remoteStreamStats(streamId) {const out = await client.readStats({ kind: 'stream', stream_id: Number(streamId) });if (!out) throw unavailable('stream stats');@@@ -341,28 +300,22 @@ async function remoteChannelMaxId(channelUserId) {/*** The newest chat id in a channel — Chat's id, so the AI context delta (channelMessagesPeek reads* Chat's ids) stays aligned. `channelMaxId` awaits Chat (and warms the peek); `channelMaxIdPeek` is-- * for the caller that cannot await and answers the last good id, else Live's own table.++ * for the caller that cannot await and answers the last good id, else 0 (Live keeps no table).*/function channelMaxId(channelUserId) {const id = Number(channelUserId) || 0;if (!id) return Promise.resolve(0);-- return load(`cmid:${id}`, () => remoteChannelMaxId(id), () => local().getMaxChatMessageIdForChannel(id))-- .then((v) => (v != null ? Number(v) || 0 : safeLocal(() => local().getMaxChatMessageIdForChannel(id), 0)));++ return load(`cmid:${id}`, () => remoteChannelMaxId(id), () => 0)++ .then((v) => (v != null ? Number(v) || 0 : 0));}function channelMaxIdPeek(channelUserId) {const id = Number(channelUserId) || 0;if (!id) return 0;-- const v = peek(`cmid:${id}`, () => remoteChannelMaxId(id), () => local().getMaxChatMessageIdForChannel(id));-- if (v != null) return Number(v) || 0;-- return safeLocal(() => local().getMaxChatMessageIdForChannel(id), 0);++ const v = peek(`cmid:${id}`, () => remoteChannelMaxId(id), () => 0);++ return v != null ? Number(v) || 0 : 0;}--function localUserMessageCount(userId) {-- const r = local().get(`SELECT COUNT(*) AS c FROM chat_messages-- WHERE user_id = ? AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [Number(userId)]);-- return Number(r && r.c) || 0;--}++function localUserMessageCount() { return 0; }async function remoteUserMessageCount(userId) {const out = await client.readStats({ kind: 'user', user_id: Number(userId) });if (!out) throw unavailable('user message count');@@@ -370,8 +323,8 @@ async function remoteUserMessageCount(userId) {}/*** A user's non-deleted chat total, for a caller that cannot await (the profile card). Answers the-- * last good value, else null when neither Chat nor (in dev / rollback) Live's table answered — the-- * card omits the count then rather than presenting a cold mirror number as real.++ * last good value, else null when Chat did not answer — the card omits the count then rather than++ * presenting a cold zero as real.*/function userMessageCountPeek(userId) {const id = Number(userId) || 0;@@@ -412,7 +365,7 @@ function searchMessages({ query = '', userId = null, anonId = null, username = nif (query) { const q = String(query).toLowerCase(); rows = rows.filter((m) => String(m.message || '').toLowerCase().includes(q)); }const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);return { messages: page, total: rows.length + offset };-- }, () => local().searchChatMessages({ query, userId, anonId, username, streamId, limit, offset }), { strict: true });++ }, () => ({ messages: [], total: 0 }), { strict: true });}/** One message by id (the moderation delete path checks the row it is about). Live, never cached. */@@@ -422,7 +375,7 @@ function messageById(id) {const out = await client.readMessages({ id: Number(id) });if (!out || !Array.isArray(out.messages)) throw unavailable('message');return out.messages[0] || null;-- }, () => local().getChatMessageById(Number(id)) || null, { ttl: 0, strict: true });++ }, () => null, { ttl: 0, strict: true });}/** Newest `limit` messages of a channel, oldest→newest (the AI persona's chat delta reads the cache). */@@@ -434,7 +387,7 @@ function channelMessages(channelUserId, limit = 40) {const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');return out.messages.slice().reverse(); // Chat answers newest-first; the caller wants oldest-first-- }, () => local().getChannelChatSince(id, 0, limit) || []);++ }, () => []);}function channelMessagesPeek(channelUserId, limit = 40) {const id = Number(channelUserId) || 0;@@@ -443,7 +396,7 @@ function channelMessagesPeek(channelUserId, limit = 40) {const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');return out.messages.slice().reverse();-- }, () => local().getChannelChatSince(id, 0, limit) || []) || [];++ }, () => []) || [];}/** A user's chat history page (admin console): { messages, total }. */@@@ -458,7 +411,7 @@ function userHistory(userId, { limit = 50, offset = 0 } = {}) {const rows = offset ? out.messages.slice(offset, offset + limit) : out.messages.slice(0, limit);// Chat's read API answers a page, not a count; a lower bound keeps "load more" honest.return { messages: rows, total: rows.length + offset };-- }, () => local().getUserChatHistory(id, limit, offset), { strict: true });++ }, () => ({ messages: [], total: 0 }), { strict: true });}// A relay row's username is stored prefixed: "[Label] name". The labels are the ones the relay@@@ -480,7 +433,7 @@ function relayHistory(platform, username, { limit = 50, offset = 0, query = '' }const rows = out.messages.filter((m) => (m.source_platform || '') === String(platform).toLowerCase());const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);return { messages: page, total: rows.length + offset };-- }, () => local().getRelayUserChatHistory(platform, username, { limit, offset, query }));++ }, () => ({ messages: [], total: 0 }));}/** A chatter's recent messages in ONE channel (an AI-viewer clone source); relay matches by handle. */@@@ -497,7 +450,7 @@ function channelSamples(channelUserId, { userId = null, relay = null, limit = 30const out = await client.readMessages({ user_id: Number(userId), limit: Math.min(500, limit * 4) });if (!out || !Array.isArray(out.messages)) throw unavailable('channel samples');return out.messages.filter((m) => Number(m.channel_user_id) === Number(channelUserId)).slice(0, limit);-- }, () => local().getChatSamplesInChannel(channelUserId, { userId, relay, limit }));++ }, () => []);}/** A channel's chat volume over the last `windowSec`, in `bucketSec` buckets: [{ count, tsEpoch }]. */@@@ -510,7 +463,7 @@ function liveChatBuckets(streamId, windowSec = 150, bucketSec = 15) {const out = await client.readTimeline({ stream_id: id, since, bucket_ms: bucketSec * 1000 });if (!out || !Array.isArray(out.buckets)) throw unavailable('timeline');return out.buckets.map((b) => ({ count: Number(b.count) || 0, tsEpoch: Math.round(Number(b.t) / 1000) }));-- }, () => local().getLiveChatBuckets(id, windowSec, bucketSec) || []);++ }, () => []);}/** The message texts of a stream in the last `sinceSec` seconds (AI clip confirmation), oldest→newest. */@@@ -526,7 +479,7 @@ function recentChatText(streamId, sinceSec = 120, limit = 40) {.filter((m) => m.message && Date.parse(String(m.timestamp).replace(' ', 'T') + 'Z') >= floor).map((m) => String(m.message)).reverse();-- }, () => local().getRecentChatText(id, sinceSec, limit) || []);++ }, () => []);}/** The busiest `bucketSec` time-buckets of a stream, as offsets from `sinceMs`: [{ offset, count }]. */@@@ -542,21 +495,18 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {.map((b) => ({ offset: Math.max(0, Math.round((Number(b.t) - base) / 1000 / bucketSec) * bucketSec), count: Number(b.count) || 0 })).sort((a, b) => b.count - a.count || a.offset - b.offset).slice(0, topN);-- }, () => local().getChatSpikeOffsets(id, bucketSec, topN) || []);++ }, () => []);}// ── First chat (the welcome check) ───────────────────────────────────────────// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller--// uses firstChatPeek. In chat mode a cold cache or a Chat outage answers false — Live's own copy is--// a frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.++// uses firstChatPeek. A cold cache or a Chat outage answers false — Live keeps no copy of the table.const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stableconst FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat--function firstChatLocal(identity, channelUserId) {-- return safeLocal(() => local().isFirstChatInChannel(identity, channelUserId), false);--}++function firstChatLocal() { return false; }function firstChatRemote(channelUserId, identity) {return async () => {const out = await client.readFirstChat({ channel_id: channelUserId, identity });@@@ -569,23 +519,23 @@ function firstChat(channelUserId, identity) {const channelId = Number(channelUserId) || 0;const key = String(identity || '');if (!channelId || !key) return Promise.resolve(false);-- return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {++ return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(), {ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),}).then((v) => {if (v === true || v === false) return v;-- return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy++ return false; // Chat unreachable: not first (Live keeps no stream_first_chats)});}--/** The synchronous form (the AI persona prompt): the cached Chat answer, else Live's own table when Live runs chat. */++/** The synchronous form (the AI persona prompt): the cached Chat answer, else false. */function firstChatPeek(channelUserId, identity) {const channelId = Number(channelUserId) || 0;const key = String(identity || '');if (!channelId || !key) return false;-- const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {++ const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(), {ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),});if (v === true || v === false) return v;-- return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy++ return false; // Chat unreachable: not first (Live keeps no stream_first_chats)}// ── Moderation queues ─────────────────────────────────────────────────────────@@@ -601,7 +551,7 @@ function pendingIp(channelId, { limit = 50 } = {}) {const out = await client.readPendingIp({ channel_id: id, limit });if (!out || !Array.isArray(out.pending_ip)) throw unavailable('pending IP queue');return out.pending_ip;-- }, () => local().getPendingIpMessages(id, { limit }) || [], { ttl: 0, strict: true });++ }, () => [], { ttl: 0, strict: true });}/** The hidden relay users of a channel (plus the site-wide rows): [{ … }]. */@@@ -612,7 +562,7 @@ function relayUsers(channelId, { limit = 100 } = {}) {const out = await client.readRelayUsers({ channel_id: id || undefined, limit });if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');return out.relay_users;-- }, () => local().getHiddenRelayUsers(id, { limit }) || [], { ttl: 0, strict: true });++ }, () => [], { ttl: 0, strict: true });}/** One hidden relay user by id: { id, channel_id, … } | null. */@@@ -623,26 +573,20 @@ function relayUser(id) {const out = await client.readRelayUser(rid);if (!out) throw unavailable('hidden relay user');return out.relay_user || null;-- }, () => {-- const r = local().get('SELECT id, channel_id FROM hidden_relay_users WHERE id = ?', [rid]);-- return r || null;-- }, { ttl: 0, strict: true });++ }, () => null, { ttl: 0, strict: true });}/*** Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay* path can carry a message per second and must not wait on an HTTP call.*-- * Chat owns hidden_relay_users, so in chat mode Chat's list is the only truth: the fresh answer-- * when there is one, else the last good list while a refresh runs in the background. A cold cache-- * (Chat has never answered) fails OPEN — never drop a relayed line on a Chat outage — and Live's-- * own copy (frozen since the mirror's retirement) is never consulted. A Live-route hide/unhide-- * invalidates the cached list, so the next check re-asks Chat. Live's own table answers only when-- * Live is not in chat mode.++ * Chat owns hidden_relay_users, so Chat's list is the only truth: the fresh answer when there is++ * one, else the last good list while a refresh runs in the background. A cold cache (Chat has never++ * answered) fails OPEN — never drop a relayed line on a Chat outage. A Live-route hide/unhide++ * invalidates the cached list, so the next check re-asks Chat. Outside chat mode: false.*/function isRelayUserHidden(channelId, platform, username) {-- const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };-- if (!remote()) return localHidden();++ if (!remote()) return false; // Live keeps no hidden_relay_usersconst key = `hru:${Number(channelId) || 0}`;const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));const read = async () => {@@@ -658,7 +602,7 @@ function isRelayUserHidden(channelId, platform, username) {return matches(last.value); // the last good list, while the refresh runs}load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });-- return false; // never answered: fail open, never Live's frozen table++ return false; // never answered: fail open}/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */@@@ -669,12 +613,11 @@ function ttsOverride(identityKey) {const out = await client.readTtsOverride({ identity_key: k });if (!out) throw unavailable('tts override');return out.tts_override || null;-- }, () => local().getTtsVoiceOverride(k), { ttl: 0, strict: true });++ }, () => null, { ttl: 0, strict: true });}/*** The TTS engine's synchronous read: the last good override while Chat refreshes in the background.-- * In chat mode a cache miss or a Chat failure answers null (the auto voice) — Live's own copy is a-- * frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.++ * A cache miss or a Chat failure answers null (the auto voice) — Live keeps no copy of the table.*/function ttsOverridePeek(identityKey) {const k = String(identityKey || '').trim().toLowerCase();@@@ -683,7 +626,7 @@ function ttsOverridePeek(identityKey) {const out = await client.readTtsOverride({ identity_key: k });if (!out) throw unavailable('tts override');return out.tts_override || null;-- }, () => local().getTtsVoiceOverride(k));++ }, () => null);}// ── Channel sounds ────────────────────────────────────────────────────────────@@@ -695,10 +638,7 @@ function soundCount(ownerId) {const out = await client.readSounds({ channel_owner_id: id });if (!out || out.count == null) throw unavailable('sound count');return Number(out.count);-- }, () => {-- const r = local().get('SELECT COUNT(*) AS count FROM channel_sounds WHERE channel_owner_id = ?', [id]);-- return Number(r && r.count) || 0;-- });++ }, () => 0);}const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');@@@ -707,21 +647,20 @@ async function remoteSoundByCommand(ownerId, command) {if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answerreturn out.sound || false; // the sound, or Chat's definitive none}--/** The approved sound a !command plays (or null). Chat owns the row in chat mode; Live's own table answers only when Live runs chat. */++/** The approved sound a !command plays (or null). Chat owns the row; Live keeps no copy. */function soundByCommand(ownerId, command) {const id = Number(ownerId) || 0;const cmd = normalizeCommand(command);if (!id || !cmd) return Promise.resolve(null);-- const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;-- return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn)-- .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable — never Live's frozen row++ return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => null)++ .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable}/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */function soundByCommandPeek(ownerId, command) {const id = Number(ownerId) || 0;const cmd = normalizeCommand(command);if (!id || !cmd) return null;-- const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => local().getChannelSoundByCommand(id, cmd) || null);++ const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => null);return v && v !== false ? v : null;}@@@ -730,9 +669,9 @@ const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is plconst DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly/*** Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's-- * block-state read (chat.messages.read); Live's own dm.js table answers only when Live is not in-- * chat mode. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it —-- * blocks are rare).++ * block-state read (chat.messages.read); outside chat mode there is no local table and the answer++ * is null. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it — blocks++ * are rare).** `strict`: past the cache and with Chat unreachable the answer is null, never the last good* value, and the call-invite route fails closed on it — refusing a ring is far better than ringing@@@ -747,7 +686,7 @@ function dmBlocked(a, b) {const out = await client.readDmBlockState({ a: x, b: y });if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');return out.blocked;-- }, () => safeLocal(() => require('./dm').isBlockedEither(x, y), null), {++ }, () => null, { // Live keeps no dm_blocks; outside chat mode the gate fails closedstrict: true,ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),}).catch((err) => {@@@ -766,23 +705,13 @@ function pendingSounds({ channelOwnerId, afterId, limit = 100 } = {}) {const out = await client.readSounds({ pending_asset: true, channel_owner_id: channelOwnerId || undefined, after_id: afterId || undefined, limit });if (!out || !Array.isArray(out.sounds)) throw unavailable('pending sounds');return out.sounds;-- }, () => {-- const db = local();-- const params = [];-- let sql = 'SELECT * FROM channel_sounds WHERE media_asset_id IS NULL';-- if (channelOwnerId) { sql += ' AND channel_owner_id = ?'; params.push(Number(channelOwnerId)); }-- if (afterId) { sql += ' AND id > ?'; params.push(Number(afterId)); }-- params.push(limit);-- return db.all(`${sql} ORDER BY id LIMIT ?`, params) || [];-- }, { ttl: 0 });++ }, () => [], { ttl: 0 });}/** Record where the asset sync put a channel sound (Chat owns the row; a repeat is a no-op). */async function recordSoundAsset(id, mediaUrl, mediaAssetId) {if (!(Number(id) > 0) || !(Number(mediaAssetId) > 0) || !mediaUrl) return null;-- if (!remote()) {-- try { local().run('UPDATE channel_sounds SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl, mediaAssetId, id]); return {}; } catch { return null; }-- }++ if (!remote()) return null; // Live keeps no channel_sounds; nothing to record onreturn client.soundAsset({ id: Number(id), media_url: String(mediaUrl), media_asset_id: Number(mediaAssetId) });}@@@ -2,33 +2,20 @@// ═══════════════════════════════════════════════════════════════// Deploy notices in chat — one tidy, rolling message instead of a line per restart.//--// What went wrong before: every boot inserted a chat row holding the latest three commit subjects--// (which overlap from one restart to the next) with a relative time baked into the text ("2m ago"--// forever). Fourteen deploys made fourteen rows repeating each other.--//--// Now:--// • Only commits that were never announced are announced (site setting `deploy_last_announced`).--// A restart with no new code says nothing.--// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a--// deploy notice (nobody has spoken anywhere since) and it is under 3 hours old, it is updated in place.--// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,--// updated_at }. Clients render times from ISO timestamps, so they are always right.--// • The live broadcast carries the row id; clients replace the card with that id instead of--// appending, so reconnects and repeats can never duplicate it.--// • The deploy is also a durable OpenVibe.Events event, live.release.deployed--// (server/events/release-events.js), queued in the SAME transaction that records the commits--// as announced (and, without the Chat service, stores the chat row). OpenVibe.Chat consumes--// that event and folds the card by head commit (compatibility register C-84, proven on the--// 2026-09-23 22:14 UTC deploy). With Events publishing off there is no other way to Chat (its--// ingress has no deploy endpoint), so the commits stay unannounced and the next boot tries again.++// Only commits that were never announced are announced (site setting `deploy_last_announced`); a++// restart with no new code says nothing. The deploy is a durable OpenVibe.Events event,++// live.release.deployed (server/events/release-events.js), queued in the SAME transaction that++// records the commits as announced. OpenVibe.Chat consumes that event, stores the rolling message++// (folding consecutive deploys into ONE row by head commit — compatibility register C-84, proven on++// the 2026-09-23 22:14 UTC deploy) and broadcasts the card. With Events publishing off there is no++// other way to Chat (its ingress has no deploy endpoint), so the commits stay unannounced and the++// next boot tries again.// ═══════════════════════════════════════════════════════════════const { execFile } = require('child_process');const path = require('path');const REPO_DIR = path.join(__dirname, '..', '..');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 git = (args, timeout = 5000) => new Promise((resolve) => {@@@ -56,30 +43,6 @@ async function newCommits(db) {return { head, previous, commits: parseLog(raw) };}--const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;----/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */--function persist(db, commits) {-- const nowIso = new Date().toISOString();-- // Newest across EVERY room: the global feed shows stream and channel messages too, so folding-- // while people chatted in a stream left a card whose time range ran past the messages under it.-- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');-- let prev = null;-- if (newest && newest.message_type === 'system' && newest.metadata) {-- try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }-- }-- if (prev) {-- const seen = new Set(commits.map(c => c.hash));-- const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);-- const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };-- db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);-- return { id: newest.id, meta };-- }-- const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };-- const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });-- return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };--}--/*** Announce this boot's new commits. Safe to call once after boot.* @returns {Promise<{ announced: number, event_id?: string|null }>}@@@ -103,23 +66,14 @@ async function announce({ db, log = console }) {};const inTransaction = (fn) => db.getDb().transaction(fn)();-- // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling-- // message and shows it (its own copy of this module). It learns the commits from the-- // live.release.deployed event only. Chat's ingress has no deploy endpoint: with Events publishing off-- // the commits are not recorded as announced and the next boot tries again (OpenVibe.Chat docs/chat-ingress.md).-- if (require('./chat-delivery').ingress()) {-- if (!outbox) { log.warn('[Deploy notice] Events outbox unavailable; left unannounced for the next boot'); return { announced: 0 }; }-- try { inTransaction(recordDeploy); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); return { announced: commits.length, event_id: null }; }-- if (outbox) outbox.kick();-- return { announced: commits.length, event_id: eventId };-- }---- let saved;-- try { saved = inTransaction(() => { const s = persist(db, commits); recordDeploy(); return s; }); }-- catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }-- if (outbox) outbox.kick();--++ // Live still decides what shipped; OpenVibe.Chat stores the rolling message and shows it (its++ // own copy of this module). It learns the commits from the live.release.deployed event only.++ // Chat's ingress has no deploy endpoint: with Events publishing off the commits are not++ // recorded as announced and the next boot tries again (OpenVibe.Chat docs/chat-ingress.md).++ if (!outbox) { log.warn('[Deploy notice] Events outbox unavailable; left unannounced for the next boot'); return { announced: 0, event_id: null }; }++ try { inTransaction(recordDeploy); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); return { announced: 0, event_id: null }; }++ outbox.kick();return { announced: commits.length, event_id: eventId };}--module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };++module.exports = { announce, newCommits, parseLog, SETTING };ddeleted file mode 100644@@@ -1,477 +0,0 @@--/**-- * OpenVibe.Live — Direct Message System-- *-- * Facebook Messenger-style DMs: 1-on-1 and group conversations,-- * persisted to SQLite, real-time delivery via WebSocket, with-- * read receipts and unread counts.-- *-- * Tables:-- * dm_conversations — conversation metadata-- * dm_participants — who's in each conversation-- * dm_messages — individual messages-- */--const db = require('../db/database');----// ── Schema & Migrations ──────────────────────────────────────----function ensureTables() {-- const database = db.getDb();-- try {-- database.exec(`-- CREATE TABLE IF NOT EXISTS dm_conversations (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- name TEXT,-- is_group INTEGER DEFAULT 0,-- created_by INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE-- );---- CREATE TABLE IF NOT EXISTS dm_participants (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- conversation_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',-- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(conversation_id, user_id),-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );---- CREATE TABLE IF NOT EXISTS dm_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- conversation_id INTEGER NOT NULL,-- sender_id INTEGER NOT NULL,-- message TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,-- FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE-- );---- CREATE INDEX IF NOT EXISTS idx_dm_participants_conv ON dm_participants(conversation_id);-- CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);-- CREATE INDEX IF NOT EXISTS idx_dm_messages_conv ON dm_messages(conversation_id, created_at);-- CREATE INDEX IF NOT EXISTS idx_dm_messages_sender ON dm_messages(sender_id);---- CREATE TABLE IF NOT EXISTS dm_blocks (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- blocker_id INTEGER NOT NULL,-- blocked_id INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(blocker_id, blocked_id),-- FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocker ON dm_blocks(blocker_id);-- CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocked ON dm_blocks(blocked_id);-- `);-- console.log('[DM] Tables ready');-- } catch (e) {-- console.warn('[DM] Table migration:', e.message);-- }--}----// ── Conversation helpers ─────────────────────────────────────----/**-- * Find an existing 1-on-1 conversation between two users.-- * Returns conversation row or null.-- */--function findDirectConversation(userIdA, userIdB) {-- return db.get(`-- SELECT c.* FROM dm_conversations c-- JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?-- JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?-- WHERE c.is_group = 0-- `, [userIdA, userIdB]) || null;--}----/**-- * Create a new conversation. Returns the new conversation id.-- * participantIds should include the creator.-- */--function createConversation(createdBy, participantIds, name = null) {-- const isGroup = participantIds.length > 2 ? 1 : 0;-- const result = db.run(-- `INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,-- [isGroup ? (name || null) : null, isGroup, createdBy]-- );-- const convId = result.lastInsertRowid;-- const insert = db.getDb().prepare(-- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id) VALUES (?, ?)`-- );-- for (const uid of participantIds) {-- insert.run(convId, uid);-- }-- return convId;--}----/**-- * Get or create a 1-on-1 conversation between two users.-- */--function getOrCreateDirect(userIdA, userIdB) {-- const existing = findDirectConversation(userIdA, userIdB);-- if (existing) return existing.id;-- return createConversation(userIdA, [userIdA, userIdB]);--}----/**-- * Add a participant to an existing group conversation.-- */--function addParticipant(conversationId, userId) {-- db.run(-- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id) VALUES (?, ?)`,-- [conversationId, userId]-- );-- // Mark as group if > 2 participants-- const count = db.get(-- `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,-- [conversationId]-- )?.c || 0;-- if (count > 2) {-- db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);-- }--}----/**-- * Remove a participant from a group conversation.-- */--function removeParticipant(conversationId, userId) {-- db.run(-- `DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,-- [conversationId, userId]-- );--}----/**-- * Check if a user is a participant in a conversation.-- */--function isParticipant(conversationId, userId) {-- return !!db.get(-- `SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,-- [conversationId, userId]-- );--}----/**-- * Rename a group conversation.-- */--function renameConversation(conversationId, name) {-- db.run(`UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, conversationId]);--}----/**-- * Get all conversations for a user with last message preview and unread count.-- */--function getConversations(userId) {-- return db.all(`-- SELECT-- c.id,-- c.name,-- c.is_group,-- c.updated_at,-- (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message,-- (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id,-- (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at,-- (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count-- FROM dm_conversations c-- JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ?-- ORDER BY-- COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC-- `, [userId, userId]);--}----/**-- * Get participants of a conversation (with user profile info).-- */--function getParticipants(conversationId) {-- return db.all(`-- SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color-- FROM dm_participants p-- JOIN users u ON u.id = p.user_id-- WHERE p.conversation_id = ?-- `, [conversationId]);--}----/**-- * Get a single conversation by id (with participant info for the requesting user).-- */--function getConversation(conversationId) {-- return db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;--}----// ── Message helpers ──────────────────────────────────────────----/**-- * Send a message in a conversation. Returns the message row.-- */--function sendMessage(conversationId, senderId, text) {-- if (!text || !text.trim()) return null;-- const trimmed = text.trim().slice(0, 2000); // max 2000 chars-- const result = db.run(-- `INSERT INTO dm_messages (conversation_id, sender_id, message) VALUES (?, ?, ?)`,-- [conversationId, senderId, trimmed]-- );-- // Touch conversation updated_at-- db.run(`UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]);-- // Auto-mark read for sender-- markRead(conversationId, senderId);-- return db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);--}----/**-- * Get messages in a conversation (paginated, newest first).-- */--function getMessages(conversationId, limit = 50, before = null, after = null) {-- if (after) {-- // Fetch messages newer than `after` id (for live polling)-- return db.all(`-- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color-- FROM dm_messages m-- JOIN users u ON u.id = m.sender_id-- WHERE m.conversation_id = ? AND m.id > ?-- ORDER BY m.created_at ASC-- LIMIT ?-- `, [conversationId, after, limit]);-- }-- if (before) {-- return db.all(`-- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color-- FROM dm_messages m-- JOIN users u ON u.id = m.sender_id-- WHERE m.conversation_id = ? AND m.id < ?-- ORDER BY m.created_at DESC-- LIMIT ?-- `, [conversationId, before, limit]);-- }-- return db.all(`-- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color-- FROM dm_messages m-- JOIN users u ON u.id = m.sender_id-- WHERE m.conversation_id = ?-- ORDER BY m.created_at DESC-- LIMIT ?-- `, [conversationId, limit]);--}----/**-- * Mark a conversation as read for a user (set last_read_at to now).-- */--function markRead(conversationId, userId) {-- db.run(-- `UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?`,-- [conversationId, userId]-- );--}----/**-- * Get total unread message count across all conversations for a user.-- */--function getTotalUnread(userId) {-- const row = db.get(`-- SELECT COALESCE(SUM(unread), 0) as total FROM (-- SELECT COUNT(*) as unread-- FROM dm_messages m-- JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?-- WHERE m.created_at > p.last_read_at AND m.sender_id != ?-- )-- `, [userId, userId]);-- return row?.total || 0;--}----/**-- * Search users by username/display_name for the "new message" user picker.-- * Excludes the requesting user.-- */--function searchUsers(query, excludeUserId, limit = 10) {-- if (!query || query.length < 2) return [];-- return db.all(`-- SELECT id, username, display_name, avatar_url, profile_color-- FROM users-- WHERE id != ? AND is_banned = 0-- AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?)-- AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)-- AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)-- LIMIT ?-- `, [excludeUserId, excludeUserId, excludeUserId, `%${query}%`, `%${query}%`, limit]);--}----// ── Block helpers ────────────────────────────────────────────----/**-- * Check if either user has blocked the other (bidirectional).-- */--function isBlockedEither(userIdA, userIdB) {-- return !!db.get(-- `SELECT 1 FROM dm_blocks-- WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,-- [userIdA, userIdB, userIdB, userIdA]-- );--}----/**-- * Check if blocker has blocked blocked (one-directional).-- */--function hasBlocked(blockerId, blockedId) {-- return !!db.get(-- `SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,-- [blockerId, blockedId]-- );--}----/**-- * Block a user.-- */--function blockUser(blockerId, blockedId) {-- db.run(-- `INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id) VALUES (?, ?)`,-- [blockerId, blockedId]-- );--}----/**-- * Unblock a user.-- */--function unblockUser(blockerId, blockedId) {-- db.run(-- `DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,-- [blockerId, blockedId]-- );--}----/**-- * Get all users blocked by blockerId.-- */--function getBlockedUsers(blockerId) {-- return db.all(`-- SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at-- FROM dm_blocks b-- JOIN users u ON u.id = b.blocked_id-- WHERE b.blocker_id = ?-- ORDER BY b.created_at DESC-- `, [blockerId]);--}----/**-- * Delete a message (only the sender can delete their own message).-- */--function deleteMessage(messageId, userId) {-- const msg = db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);-- if (!msg || msg.sender_id !== userId) return false;-- db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);-- return true;--}----/**-- * Get the number of participants in a conversation.-- */--function getParticipantCount(conversationId) {-- const row = db.get(-- `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,-- [conversationId]-- );-- return row?.c || 0;--}----// ── Anti-spam helpers ────────────────────────────────────────----/**-- * Detect spam patterns in DM message content.-- * Returns { isSpam: boolean, reason: string|null }-- */--function checkMessageSpam(text) {-- if (!text) return { isSpam: false, reason: null };-- const trimmed = text.trim();---- // Excessive caps (>70% caps in messages over 10 chars)-- if (trimmed.length > 10) {-- const alphaChars = trimmed.replace(/[^a-zA-Z]/g, '');-- if (alphaChars.length > 5) {-- const capsRatio = (alphaChars.replace(/[^A-Z]/g, '').length) / alphaChars.length;-- if (capsRatio > 0.7) return { isSpam: true, reason: 'Excessive caps' };-- }-- }---- // Repeated characters (e.g., "aaaaaaa" or "!!!!!!")-- if (/(.)\1{9,}/i.test(trimmed)) {-- return { isSpam: true, reason: 'Repeated characters' };-- }---- // Repeated words (same word 5+ times)-- const words = trimmed.toLowerCase().split(/\s+/);-- if (words.length >= 5) {-- const freq = {};-- for (const w of words) freq[w] = (freq[w] || 0) + 1;-- for (const w of Object.keys(freq)) {-- if (freq[w] >= 5 && freq[w] / words.length > 0.6) {-- return { isSpam: true, reason: 'Repetitive content' };-- }-- }-- }---- // Common spam patterns (URLs in bulk, typical scam patterns)-- const urlCount = (trimmed.match(/https?:\/\//gi) || []).length;-- if (urlCount >= 3) return { isSpam: true, reason: 'Too many URLs' };---- return { isSpam: false, reason: null };--}----/**-- * Check if this message is a duplicate of the user's recent messages.-- * Returns true if the exact same text was sent within the last N seconds.-- */--function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {-- const row = db.get(`-- SELECT 1 FROM dm_messages-- WHERE conversation_id = ? AND sender_id = ? AND message = ?-- AND created_at > datetime('now', '-' || ? || ' seconds')-- LIMIT 1-- `, [conversationId, senderId, text.trim().slice(0, 2000), windowSeconds]);-- return !!row;--}----/**-- * Check if a user account is too new to send DMs (minimum account age).-- * Returns { tooNew: boolean, minutesRemaining: number }-- */--function isAccountTooNew(userId, minMinutes = 5) {-- const user = db.get(`SELECT created_at FROM users WHERE id = ?`, [userId]);-- if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };-- const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');-- const ageMs = Date.now() - created.getTime();-- const ageMinutes = ageMs / 60000;-- if (ageMinutes < minMinutes) {-- return { tooNew: true, minutesRemaining: Math.ceil(minMinutes - ageMinutes) };-- }-- return { tooNew: false, minutesRemaining: 0 };--}----module.exports = {-- ensureTables,-- findDirectConversation,-- createConversation,-- getOrCreateDirect,-- addParticipant,-- removeParticipant,-- isParticipant,-- renameConversation,-- getConversations,-- getParticipants,-- getConversation,-- sendMessage,-- getMessages,-- markRead,-- getTotalUnread,-- searchUsers,-- isBlockedEither,-- hasBlocked,-- blockUser,-- unblockUser,-- getBlockedUsers,-- deleteMessage,-- getParticipantCount,-- checkMessageSpam,-- isDuplicateMessage,-- isAccountTooNew,--};@@@ -623,8 +623,7 @@ effectsRouter.post('/notify/dm-read', (req, res) => {});// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the--// rows since T3). Chat sends the Media asset id it holds on `asset_id`; Live's own channel_sounds--// copy is a frozen pre-retirement snapshot and is never read for it.++// rows since T3, and Live keeps no copy). Chat sends the Media asset id it holds on `asset_id`.effectsRouter.post('/asset-sync', (req, res) => {try {const sync = require('../media-proxy/asset-sync');@@@ -413,81 +413,6 @@ function initDb() {database.exec(`CREATE INDEX IF NOT EXISTS idx_linked_user ON linked_accounts(user_id)`);} catch (e) { console.warn('[DB] linked_accounts migration:', e.message); }-- // Migrate: make chat_messages.stream_id nullable (was NOT NULL, broke global chat saves)-- try {-- const cmCols = database.prepare("PRAGMA table_info('chat_messages')").all();-- const streamIdCol = cmCols.find(c => c.name === 'stream_id');-- if (streamIdCol && streamIdCol.notnull === 1) {-- database.exec(`-- CREATE TABLE chat_messages_new (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- stream_id INTEGER,-- user_id INTEGER,-- anon_id TEXT,-- username TEXT,-- message TEXT NOT NULL,-- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts')),-- is_global INTEGER DEFAULT 0,-- is_deleted INTEGER DEFAULT 0,-- is_filtered INTEGER DEFAULT 0,-- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL-- );-- INSERT INTO chat_messages_new SELECT * FROM chat_messages;-- DROP TABLE chat_messages;-- ALTER TABLE chat_messages_new RENAME TO chat_messages;-- CREATE INDEX IF NOT EXISTS idx_chat_stream ON chat_messages(stream_id);-- CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(user_id);-- CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);-- `);-- console.log('[DB] Migrated chat_messages.stream_id to nullable');-- }-- } catch (e) { console.warn('[DB] chat_messages migration:', e.message); }---- // Migrate: add reply_to_id column to chat_messages for threaded replies-- try {-- const cmCols2 = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => c.name);-- if (!cmCols2.includes('reply_to_id')) {-- database.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL`);-- console.log('[DB] Added reply_to_id column to chat_messages');-- }-- } catch (e) { console.warn('[DB] chat_messages reply_to_id migration:', e.message); }---- // Migrate: widen the message_type CHECK so sound announces ('channel-sound',-- // 'soundboard') persist to history. SQLite can't alter a CHECK — rebuild the-- // table from its CURRENT definition (columns have grown over time).-- try {-- const tbl = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chat_messages'").get();-- const REQUIRED_TYPES = ['channel-sound', 'soundboard', 'clip'];-- if (tbl && /message_type IN \([^)]*\)/.test(tbl.sql) && REQUIRED_TYPES.some(t => !tbl.sql.includes(`'${t}'`))) {-- const newSql = tbl.sql-- .replace(/CREATE TABLE ["']?chat_messages["']?/, 'CREATE TABLE chat_messages_new')-- .replace(/message_type IN \([^)]*\)/,-- "message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')");-- const cols = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => `"${c.name}"`).join(', ');-- const indexes = database.prepare(-- "SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL"-- ).all().map(r => r.sql);-- database.pragma('foreign_keys = OFF');-- try {-- database.exec('BEGIN');-- database.exec(newSql);-- database.exec(`INSERT INTO chat_messages_new (${cols}) SELECT ${cols} FROM chat_messages`);-- database.exec('DROP TABLE chat_messages');-- database.exec('ALTER TABLE chat_messages_new RENAME TO chat_messages');-- for (const sql of indexes) { try { database.exec(sql); } catch { /* index name may survive */ } }-- database.exec('COMMIT');-- console.log('[DB] Widened chat_messages.message_type CHECK for sound announces');-- } catch (e2) {-- try { database.exec('ROLLBACK'); } catch { /* no open tx */ }-- throw e2;-- } finally {-- database.pragma('foreign_keys = ON');-- }-- }-- } catch (e) { console.warn('[DB] chat_messages message_type migration:', e.message); }--// Migrate: add default_vod_visibility / default_clip_visibility to channelstry {const chanCols = database.prepare("PRAGMA table_info('channels')").all().map(c => c.name);@@@ -1336,18 +1261,6 @@ function initDb() {)`);database.exec(`CREATE INDEX IF NOT EXISTS idx_egg_solves_date ON easter_egg_solves(egg_date)`);-- // Admin-set per-user TTS voice overrides (keyed by the same identity key used at-- // synthesis: "user:<username>" / "anon:<id>"). Overrides the auto-assigned voice.-- database.exec(`CREATE TABLE IF NOT EXISTS tts_voice_overrides (-- identity_key TEXT PRIMARY KEY,-- voice TEXT,-- pitch INTEGER,-- speed INTEGER,-- gap INTEGER DEFAULT 0,-- set_by INTEGER,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- )`);--// `pastes` is created several hundred lines below this point, so on a brand-new database// this PRAGMA throws — and because it shares a try block with the migrations that follow,// it took them down with it: streams.ai_overview, vods.ai_overview, clips.ai_overview and@@@ -1448,37 +1361,6 @@ function initDb() {}} catch (e) { console.warn('[DB] short-overview migration:', e.message); }-- // Chat-relay users (external Twitch/Kick/YouTube handles): record their first-- // message as a "join date" + last-seen + message count, for the context menu.-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS relay_users (-- platform TEXT NOT NULL,-- username TEXT NOT NULL,-- display_name TEXT,-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- message_count INTEGER DEFAULT 0,-- PRIMARY KEY (platform, username)-- )`);-- // One-time backfill: earlier relay sources (notably RobotStreamer) didn't record-- // relay_users, so their existing chatters had no row to hang chat-logs/AI insight-- // off. Seed relay_users from the historical "[Label] name" relay messages. Idempotent-- // (INSERT OR IGNORE keyed on platform+username), guarded so it runs at most once.-- const _relayDone = database.prepare("SELECT value FROM site_settings WHERE key='relay_users_backfilled'").get();-- if (!_relayDone) {-- database.exec(`INSERT OR IGNORE INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)-- SELECT source_platform,-- LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2))),-- TRIM(SUBSTR(username, INSTR(username, '] ') + 2)),-- MIN(timestamp), MAX(timestamp), COUNT(*)-- FROM chat_messages-- WHERE user_id IS NULL AND source_platform IS NOT NULL-- AND username LIKE '[%] %' AND INSTR(username, '] ') > 0-- GROUP BY source_platform, LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2)))`);-- database.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('relay_users_backfilled','1','string')").run();-- }-- } catch (e) { console.warn('[DB] relay_users migration:', e.message); }--// Timestamped transcript segments (JSON) — contextual data for the AI system +// clickable timestamps in the VOD/clip transcript UI.try {@@@ -1503,14 +1385,6 @@ function initDb() {if (!cols.includes('sort_order')) database.exec('ALTER TABLE donation_goals ADD COLUMN sort_order INTEGER DEFAULT 0');} catch (e) { console.warn('[DB] donation_goals media migration:', e.message); }-- // chat_messages.metadata — JSON sidecar for rich/animated events (donations + goal-- // reached) so they persist in history WITH their structured payload (image, amount,-- // goal title, animation kind) while `message` stays human-readable.-- try {-- const cols = database.prepare('PRAGMA table_info(chat_messages)').all().map(c => c.name);-- if (!cols.includes('metadata')) database.exec('ALTER TABLE chat_messages ADD COLUMN metadata TEXT');-- } catch (e) { console.warn('[DB] chat metadata migration:', e.message); }--// Migrate: extend the subscriptions table for real recurring billing.try {const cols = database.pragma('table_info(subscriptions)').map(c => c.name);@@@ -1567,67 +1441,6 @@ function initDb() {}} catch (e) { console.warn('[DB] Role migration:', e.message); }-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS channel_sounds (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_owner_id INTEGER NOT NULL,-- command TEXT NOT NULL,-- url TEXT NOT NULL,-- mime TEXT DEFAULT 'audio/mpeg',-- duration_seconds REAL DEFAULT 0,-- created_by INTEGER,-- created_by_name TEXT DEFAULT '',-- is_approved INTEGER DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE-- )`);-- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id)');-- } catch (e) { console.warn('[DB] channel_sounds migration:', e.message); }---- // Migrate: allow MULTIPLE sounds per command (random pick on playback).-- // Older DBs created channel_sounds with UNIQUE(channel_owner_id, command);-- // rebuild the table without it. Idempotent — only fires while the constraint exists.-- try {-- const row = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_sounds'").get();-- if (row && /UNIQUE\s*\(\s*channel_owner_id\s*,\s*command\s*\)/i.test(row.sql)) {-- database.exec('PRAGMA foreign_keys=OFF');-- const tx = database.transaction(() => {-- database.exec(`CREATE TABLE channel_sounds_new (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_owner_id INTEGER NOT NULL,-- command TEXT NOT NULL,-- url TEXT NOT NULL,-- mime TEXT DEFAULT 'audio/mpeg',-- duration_seconds REAL DEFAULT 0,-- created_by INTEGER,-- created_by_name TEXT DEFAULT '',-- is_approved INTEGER DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE-- )`);-- database.exec(`INSERT INTO channel_sounds_new-- (id, channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, is_approved, created_at)-- SELECT id, channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, is_approved, created_at-- FROM channel_sounds`);-- database.exec('DROP TABLE channel_sounds');-- database.exec('ALTER TABLE channel_sounds_new RENAME TO channel_sounds');-- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id)');-- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)');-- });-- tx();-- database.exec('PRAGMA foreign_keys=ON');-- console.log('[DB] Rebuilt channel_sounds without UNIQUE(command) — multiple sounds per command enabled');-- } else {-- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)');-- }-- } catch (e) { console.warn('[DB] channel_sounds multi-sound migration:', e.message); }---- // Migrate: optional emote attached to a sound command (shows the emote + "!" in chat).-- try {-- const cols = database.prepare('PRAGMA table_info(channel_sounds)').all().map(c => c.name);-- if (!cols.includes('emote_code')) database.exec("ALTER TABLE channel_sounds ADD COLUMN emote_code TEXT DEFAULT ''");-- } catch (e) { console.warn('[DB] channel_sounds emote_code migration:', e.message); }--// Migrate: AI chatbot ("fake viewers") config, one row per streamer (user)try {database.exec(`CREATE TABLE IF NOT EXISTS ai_chatbot_configs (@@@ -1747,25 +1560,6 @@ function initDb() {}} catch (e) { console.warn('[DB] channel_ai_config migration:', e.message); }-- // Migrate: create moderation_actions table for audit logging-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS moderation_actions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- scope_type TEXT NOT NULL DEFAULT 'site',-- scope_id INTEGER,-- actor_user_id INTEGER,-- target_user_id INTEGER,-- action_type TEXT NOT NULL,-- details TEXT DEFAULT '{}',-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,-- FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL-- )`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created_at DESC)`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id)`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id)`);-- } catch (e) { console.warn('[DB] moderation_actions migration:', e.message); }--// Migrate: create pastes table if missingtry {database.exec(`CREATE TABLE IF NOT EXISTS pastes (@@@ -1867,17 +1661,6 @@ function initDb() {for (const [k, v, d, t] of commentSettings) seedComment.run(k, v, d, t);} catch (e) { console.warn('[DB] paste_comments migration:', e.message); }-- // Migrate: stream_first_chats — tracks first-time chatters per streamer (for welcome messages)-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS stream_first_chats (-- chatter_key TEXT NOT NULL,-- channel_user_id INTEGER NOT NULL,-- first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (chatter_key, channel_user_id)-- )`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_sfc_channel ON stream_first_chats(channel_user_id)`);-- } catch (e) { console.warn('[DB] stream_first_chats migration:', e.message); }--// Migrate: ip_log — tracks IP addresses used by users and anonstry {database.exec(`CREATE TABLE IF NOT EXISTS ip_log (@@@ -1919,94 +1702,6 @@ function initDb() {database.exec(`CREATE INDEX IF NOT EXISTS idx_approved_ips_ip ON approved_ips(ip_address)`);} catch (e) { console.warn('[DB] approved_ips migration:', e.message); }-- // Migrate: pending_ip_messages — messages held for IP approval when anti-VPN mode is on-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS pending_ip_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_id INTEGER NOT NULL,-- stream_id INTEGER,-- ip_address TEXT NOT NULL,-- user_id INTEGER,-- anon_id TEXT,-- username TEXT,-- message TEXT NOT NULL,-- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),-- reviewed_by INTEGER,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,-- FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL-- )`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_pending_ip_channel ON pending_ip_messages(channel_id, status)`);-- } catch (e) { console.warn('[DB] pending_ip_messages migration:', e.message); }---- // Migrate: hidden_relay_users — relayed external users banned/hidden by streamer or admin-- try {-- database.exec(`CREATE TABLE IF NOT EXISTS hidden_relay_users (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_id INTEGER,-- platform TEXT NOT NULL,-- external_username TEXT NOT NULL,-- action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),-- reason TEXT,-- created_by INTEGER,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL-- )`);-- database.exec(`CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)`);-- } catch (e) { console.warn('[DB] hidden_relay_users migration:', e.message); }---- // Migrate: add deleted_by and deleted_at to chat_messages for soft-delete attribution-- try {-- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);-- if (!cols.includes('deleted_by')) {-- database.exec('ALTER TABLE chat_messages ADD COLUMN deleted_by INTEGER');-- }-- if (!cols.includes('deleted_at')) {-- database.exec("ALTER TABLE chat_messages ADD COLUMN deleted_at DATETIME");-- }-- } catch (e) { console.warn('[DB] chat_messages delete attribution migration:', e.message); }---- // Migrate: add source_platform column to chat_messages for filtering relayed messages-- try {-- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);-- if (!cols.includes('source_platform')) {-- database.exec("ALTER TABLE chat_messages ADD COLUMN source_platform TEXT");-- }-- } catch (e) { console.warn('[DB] chat_messages source_platform migration:', e.message); }---- // Migrate: add auto_delete_at to chat_messages for viewer-controlled history cleanup-- try {-- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);-- if (!cols.includes('auto_delete_at')) {-- database.exec("ALTER TABLE chat_messages ADD COLUMN auto_delete_at DATETIME");-- }-- } catch (e) { console.warn('[DB] chat_messages auto_delete_at migration:', e.message); }---- // Migrate: add channel_user_id to chat_messages — the broadcaster's user id, so-- // a streamer's chat is one persistent room across live slots AND offline periods-- // (history no longer depends on the live-session stream row surviving). Backfill-- // existing rows from their stream's owner.-- try {-- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);-- if (!cols.includes('channel_user_id')) {-- database.exec("ALTER TABLE chat_messages ADD COLUMN channel_user_id INTEGER");-- database.exec("UPDATE chat_messages SET channel_user_id = (SELECT s.user_id FROM streams s WHERE s.id = chat_messages.stream_id) WHERE stream_id IS NOT NULL AND channel_user_id IS NULL");-- console.log('[DB] Added channel_user_id to chat_messages + backfilled');-- }-- } catch (e) { console.warn('[DB] chat_messages channel_user_id migration:', e.message); }---- // Performance indexes — runs EVERY boot (CREATE INDEX IF NOT EXISTS is idempotent), so-- // it also fixes the previously-broken chat index (it referenced a non-existent-- // `created_at` column, so it never got created) and covers hot listing queries whose-- // WHERE + ORDER BY (created_at / view_count) previously did full scans + filesorts.-- try {-- // Chat history: filter by channel_user_id / stream_id, ORDER BY timestamp DESC.-- database.exec('CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)');-- database.exec('CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp)');-- // (The VOD/clip listing indexes went with the listings: VODs and clips are listed by OpenVibe.Media.)-- } catch (e) { console.warn('[DB] performance index migration:', e.message); }--// Second pass of hot-path indexes, found by tracing what the home page and the always-on// middleware actually execute. Each one replaces a recurring full table scan. They are created// individually so a table that does not exist in some deployment cannot stop the rest.@@@ -2016,8 +1711,6 @@ function initDb() {'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',// Looked up once per live stream on /api/streams, which every open home tab polls.'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',-- // The chat auto-delete sweep runs every 30s and scanned + sorted the largest table.-- 'CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at)',// Home digest, per signed-in load.'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)','CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',@@@ -2037,10 +1730,6 @@ function initDb() {// the whole streams table once per managed stream, per row. Measured on a production-scale// database: 463ms -> 18ms at limit=20, and 1533ms -> 24ms at limit=100.'CREATE INDEX IF NOT EXISTS idx_streams_managed_ended ON streams(managed_stream_id, ended_at)',-- // The hero stat board's heaviest queries count chat_messages over a time window with-- // COALESCE(is_deleted,0)=0, which forced a row fetch for every row in the window even-- // though the timestamp index was being used. These make those counts covering.-- 'CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)','CREATE INDEX IF NOT EXISTS idx_ai_viewer_log_created ON ai_viewer_log(created_at)',]) {try { database.exec(ix); } catch { /* table not present in this deployment */ }@@@ -3248,49 +2937,6 @@ function getStreamTranscriptSegments(streamId) {// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.--// Live chat-velocity window: message-count buckets over the last `windowSec`, each with a--// representative epoch timestamp — used to detect a "everyone reacted" spike and locate WHEN.--function getLiveChatBuckets(streamId, windowSec = 150, bucketSec = 15) {-- try {-- const rows = all(`-- SELECT CAST(strftime('%s', cm.timestamp) / ? AS INT) AS b,-- COUNT(*) AS n,-- MIN(CAST(strftime('%s', cm.timestamp) AS INT)) AS ts-- FROM chat_messages cm-- WHERE cm.stream_id = ? AND COALESCE(cm.is_deleted, 0) = 0-- AND cm.timestamp >= datetime('now', ?)-- GROUP BY b ORDER BY ts ASC`, [bucketSec, streamId, `-${windowSec} seconds`]) || [];-- return rows.map(r => ({ count: r.n, tsEpoch: r.ts }));-- } catch { return []; }--}----// Recent chat message texts for a stream (AI context for the live auto-clipper — what people--// were actually saying/reacting to around a spike).--function getRecentChatText(streamId, sinceSec = 120, limit = 40) {-- try {-- const rows = all(`SELECT message FROM chat_messages-- WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND message <> ''-- AND timestamp >= datetime('now', ?)-- ORDER BY timestamp DESC LIMIT ?`, [streamId, `-${Math.max(1, sinceSec)} seconds`, Math.max(1, limit)]) || [];-- return rows.map(r => String(r.message)).reverse();-- } catch { return []; }--}----// Chat-message spikes: the busiest time-buckets of a stream (offset seconds → message count),--// a proxy for "everyone reacted here". Offsets are relative to the stream's start.--function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {-- try {-- const rows = all(`-- SELECT CAST((julianday(cm.timestamp) - julianday(s.started_at)) * 86400 / ? AS INT) AS bucket,-- COUNT(*) AS n-- FROM chat_messages cm JOIN streams s ON s.id = cm.stream_id-- WHERE cm.stream_id = ? AND s.started_at IS NOT NULL AND cm.timestamp >= s.started_at-- AND COALESCE(cm.is_deleted, 0) = 0-- GROUP BY bucket HAVING bucket >= 0 ORDER BY n DESC LIMIT ?`, [bucketSec, streamId, topN]) || [];-- return rows.map(r => ({ offset: r.bucket * bucketSec, count: r.n }));-- } catch { return []; }--}--function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)@@@ -3805,12 +3451,9 @@ const HOME_SERIES = {anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },visitors: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },follows: { table: 'follows', ts: 'created_at', agg: 'COUNT(*)' },-- // Chat's site-wide per-day message/chatter series: in chat mode getHomeStatSeries answers them-- // from Chat's `site-daily` stats read (chat-reads.homeSeriesPeek); this local SQL stays the-- // dev / rollback / Chat-unreachable answer. `messages` maps to Chat's messages series, `active`-- // to its chatters series.-- messages: { table: 'chat_messages', ts: 'timestamp', agg: 'COUNT(*)', where: 'COALESCE(is_deleted, 0) = 0' },-- active: { table: 'chat_messages', ts: 'timestamp', agg: "COUNT(DISTINCT COALESCE('u' || user_id, 'a' || anon_id, 'r' || source_platform || '|' || username))", where: 'COALESCE(is_deleted, 0) = 0' },++ // No chat series here: OpenVibe.Chat owns chat_messages. `messages` and `active` are answered by++ // getHomeStatSeries from Chat's `site-daily` stats read; there is no Live-table fallback any more++ // (since 2026-10-05 Live keeps no copy of Chat's tables), so outside chat mode they answer null.sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).@@@ -3826,11 +3469,12 @@ const HOME_SERIES = {// No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is dropped// since N+2 (unlike the table itself, which N-1 still prepares against — see schema.sql).};--const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];++const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'messages', 'active', 'liveNow', 'viewersNow'];/**-- * The local (Live-table) daily series for one metric — the dev, rollback and Chat-unreachable-- * answer. getHomeStatSeries routes the two chat metrics through Chat first.++ * The daily series for one metric from Live's own tables. The two chat metrics (`messages`,++ * `active`) have no entry here: OpenVibe.Chat owns chat_messages, so getHomeStatSeries answers++ * them from Chat, and with Chat unreachable they answer null rather than a Live-table number.*/function homeSeriesLocal(metric, days = 30) {const def = HOME_SERIES[metric];@@@ -3980,9 +3624,8 @@ function _computeHomeStats() {clips: null,liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),streamers: c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),-- // OpenVibe.Chat owns chat_messages (roadmap T3); the total comes from Chat's read API when-- // it is the authority, from Live's own table when Live runs chat itself. A Chat outage-- // answers the cache, else null — never a 500 for the home page.++ // OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.++ // A Chat outage answers the cache, else null — never a 500 for the home page.chatMessages: (() => { try { const s = require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),@@@ -4568,115 +4211,6 @@ function cleanupPowerchatDeliveries(days = 3) {catch { return null; }}--// ── Chat helpers ─────────────────────────────────────────────----function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {-- // channel_user_id = the broadcaster's user id — set for all channel/stream-- // messages so a streamer's chat history survives across sessions AND offline-- // periods (independent of the live-session stream row's lifetime).-- let chanUid = channel_user_id || null;-- if (!chanUid && stream_id) { try { chanUid = getStreamById(stream_id)?.user_id || null; } catch { /* ignore */ } }-- const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));-- return run(-- `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,-- [stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr]-- );--}----function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {-- let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color-- FROM chat_messages cm-- LEFT JOIN users u ON cm.user_id = u.id-- WHERE cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;-- const params = [];---- if (query) {-- sql += ` AND cm.message LIKE ?`;-- params.push(`%${query}%`);-- }-- if (userId) {-- sql += ` AND cm.user_id = ?`;-- params.push(userId);-- }-- if (anonId) {-- sql += ` AND cm.anon_id = ?`;-- params.push(anonId);-- }-- if (username) {-- sql += ` AND LOWER(u.username) LIKE ?`;-- params.push(`%${username.toLowerCase()}%`);-- }-- if (streamId) {-- sql += ` AND cm.stream_id = ?`;-- params.push(streamId);-- }---- const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');-- const total = get(countSql, params)?.c || 0;---- sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;-- params.push(limit, offset);---- return { messages: all(sql, params), total };--}----function getUserChatHistory(userId, limit = 50, offset = 0) {-- const sql = `SELECT cm.*, s.title as stream_title-- FROM chat_messages cm-- LEFT JOIN streams s ON cm.stream_id = s.id-- WHERE cm.user_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)-- ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;-- const messages = all(sql, [userId, limit, offset]);-- const total = get(-- `SELECT COUNT(*) as c FROM chat_messages-- WHERE user_id = ? AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,-- [userId]-- )?.c || 0;-- return { messages, total };--}----// A chatter's recent messages in ONE channel (clone source for that channel's AI viewers).--// relay = { platform, rawUsername } matches relayed chatters instead of a user id.--function getChatSamplesInChannel(channelUserId, { userId = null, relay = null, limit = 30 } = {}) {-- let where = `cm.is_deleted = 0 AND cm.message_type != 'system'-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)-- AND COALESCE(cm.channel_user_id, (SELECT s.user_id FROM streams s WHERE s.id = cm.stream_id)) = ?`;-- const params = [channelUserId];-- if (relay) { where += ` AND ${_RELAY_MATCH}`; params.push(..._relayMatchParams(relay.platform, relay.rawUsername)); }-- else { where += ' AND cm.user_id = ?'; params.push(userId); }-- return all(`SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id-- FROM chat_messages cm WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ?`,-- [...params, Math.max(1, Math.min(200, limit))]);--}----// ── Per-user TTS voice overrides (admin-set) ─────────────────--function getTtsVoiceOverride(identityKey) {-- try {-- const k = String(identityKey || '').trim().toLowerCase();-- if (!k) return null;-- const r = get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);-- if (!r) return null;-- return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };-- } catch { return null; }--}--function setTtsVoiceOverride(identityKey, params, setBy) {-- const k = String(identityKey || '').trim().toLowerCase();-- if (!k) return false;-- run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,-- speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP`,-- [k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);-- return true;--}--function deleteTtsVoiceOverride(identityKey) {-- try { run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }--}--// ── Daily easter egg solves ──────────────────────────────────function recordEasterEggSolve(eggDate, solverKey, userId) {try {@@@ -4692,106 +4226,13 @@ function countEasterEggSolves(eggDate) {try { return get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate])?.n || 0; } catch { return 0; }}--// Record a chat-relay (external platform) user's activity; keeps the earliest--// first_seen as their "join date". Keyed case-insensitively by platform+username.--function recordRelayUser(platform, username) {-- if (!platform || !username) return;-- const key = String(username).toLowerCase();-- try {-- run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)-- VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)-- ON CONFLICT(platform, username) DO UPDATE SET-- last_seen = CURRENT_TIMESTAMP,-- message_count = message_count + 1,-- display_name = excluded.display_name`,-- [String(platform).toLowerCase(), key, String(username)]);-- } catch { /* non-critical */ }--}--function getRelayUserByRowid(id) {-- return get('SELECT rowid AS id, * FROM relay_users WHERE rowid = ?', [id]) || null;--}----// Relay chat messages are stored with a "[Label] name" username + source_platform and a--// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is--// case-insensitive for ASCII in SQLite), scoped to their platform.--function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }--const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username LIKE ? ESCAPE '\\'`;--function _relayMatchParams(platform, rawUsername) {-- return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];--}----// A relay user's message history (for the "Chat Logs" viewer).--function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {-- let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;-- const params = _relayMatchParams(platform, rawUsername);-- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }-- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;-- const rows = all(-- `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,-- cm.source_platform, s.title AS stream_title-- FROM chat_messages cm LEFT JOIN streams s ON cm.stream_id = s.id-- WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,-- [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]-- );-- return { messages: rows, total };--}----// ── Anonymous chatters: chat logs (mirror the relay path) ──────────--// Anon messages have user_id NULL and a stable anon_id = "anon<N>" (which also equals--// their username).--function anonSubjectId(anonId) {-- const m = /^anon(\d+)$/i.exec(String(anonId || ''));-- return m ? parseInt(m[1], 10) : 0;--}----// Anon meta for the context menu: first-seen (when their anon number was assigned)--// and first-chat (their earliest chat message), plus total message count.--function getAnonMeta(anonId) {-- const num = anonSubjectId(anonId);-- const map = num ? get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [num]) : null;-- const firstChat = get(-- `SELECT MIN(timestamp) AS t FROM chat_messages-- WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]-- )?.t || null;-- const count = get(-- `SELECT COUNT(*) AS c FROM chat_messages-- WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [String(anonId)]-- )?.c || 0;-- return {-- anon_id: anonId,-- anon_num: num || null,-- first_seen: (map && map.created_at) || firstChat, // fall back to first chat for legacy rows-- first_chat: firstChat,-- message_count: count,-- };--}----// An anon's message history (for the "Chat Logs" viewer).--function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {-- let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;-- const params = [String(anonId)];-- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }-- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;-- const rows = all(-- `SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,-- s.title AS stream_title-- FROM chat_messages cm LEFT JOIN streams s ON cm.stream_id = s.id-- WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,-- [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]-- );-- return { messages: rows, total };--}--function getUserProfile(userId) {const user = get(`SELECT id, username, display_name, avatar_url, profile_color, role,openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seenFROM users WHERE id = ?`, [userId]);if (!user) return null;-- // The user's chat total is OpenVibe.Chat's (Live's own tables when Live runs chat); a synchronous-- // peek answers the last good count, else null while Chat refreshes in the background. The profile-- // card omits the count when it is null rather than showing a cold mirror number as real.++ // The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else++ // null while Chat refreshes in the background. The profile card omits the count when it is null.user.messageCount = (() => { try { return require('../chat/chat-reads').userMessageCountPeek(userId); } catch { return null; } })();user.followerCount = get('SELECT COUNT(*) as c FROM follows WHERE streamer_id = ?', [userId])?.c || 0;user.followingCount = get('SELECT COUNT(*) as c FROM follows WHERE follower_id = ?', [userId])?.c || 0;@@@ -5366,66 +4807,6 @@ function isUsernameReserved(username) {return !!vk;}--// ── Channel sound commands (viewer-uploadable) ───────────────--function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {-- return run(-- `INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,-- [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '']-- );--}--// Update the shared emote_code for all sounds under a command (an emote is per-command).--function setChannelSoundEmote(ownerId, command, emoteCode) {-- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',-- [emoteCode || '', ownerId, String(command || '').toLowerCase()]);--}----function getChannelSounds(ownerId) {-- return all(-- 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',-- [ownerId]-- );--}----function getChannelSoundByCommand(ownerId, command) {-- // A command may have multiple uploaded sounds — pick one at random each play.-- return get(-- 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',-- [ownerId, String(command || '').toLowerCase()]-- );--}----function getChannelSoundById(id) {-- return get('SELECT * FROM channel_sounds WHERE id = ?', [id]);--}----function countChannelSounds(ownerId) {-- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);-- return row ? row.count : 0;--}----function countChannelSoundsByUploader(ownerId, uploaderId) {-- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);-- return row ? row.count : 0;--}----function deleteChannelSound(id) {-- return run('DELETE FROM channel_sounds WHERE id = ?', [id]);--}----// Rename a whole !command group (a command may hold several sounds).--function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {-- return run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',-- [String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);--}----// Sounds attach emotes BY CODE — keep those references alive when an emote--// is renamed so the streamer's emote+sound combos don't silently break.--function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {-- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',-- [newCode || '', ownerId, oldCode]);--}--// ── AI chatbot config (per streamer) ─────────────────────────const AI_CHATBOT_DEFAULTS = {enabled: 0,@@@ -5613,17 +4994,6 @@ function getAiViewerLogStats(channelUserId, sinceMin = 60) {SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cachedFROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)`, [channelUserId, `-${sinceMin} minutes`]);}--// Channel chat (all surfaces: native, relayed, bot lines) newer than an id — what the bots "see".--function getChannelChatSince(channelUserId, afterId = 0, limit = 60) {-- return all(`SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp-- FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation')-- AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP)-- ORDER BY id DESC LIMIT ?`, [channelUserId, afterId, Math.max(1, Math.min(200, limit))]).reverse();--}--function getMaxChatMessageIdForChannel(channelUserId) {-- return get('SELECT COALESCE(MAX(id),0) AS m FROM chat_messages WHERE channel_user_id = ?', [channelUserId])?.m || 0;--}--function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';if (activeOnly) sql += ' AND is_active = 1';@@@ -5923,22 +5293,6 @@ function renormalizePendingMediaRequestPositions(streamerId) {// table is read-only: the one-time import (OpenVibe.Community scripts/import-live-comments.js)// reads it; test/frozen-tables.test.js fails if server code writes it again.--// ── Chat replay helpers ──────────────────────────────────────----function getChatReplay(streamId, fromTime, toTime) {-- let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name-- FROM chat_messages cm-- LEFT JOIN users u ON cm.user_id = u.id-- WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;-- const params = [streamId];-- // The same bounds as a purge (see deleteChatMessagesByTimeRange): either timestamp form, as UTC.-- if (fromTime) { sql += ` AND cm.timestamp >= datetime(?)`; params.push(fromTime); }-- if (toTime) { sql += ` AND cm.timestamp <= datetime(?)`; params.push(toTime); }-- sql += ` ORDER BY cm.timestamp ASC`;-- return all(sql, params);--}--// ── Channel lookup by ID ─────────────────────────────────────function getChannelById(id) {@@@ -6038,230 +5392,6 @@ function loadAnonMappings() {return { maxNum, mappings };}--// ── Stream First Chats (Welcome Messages) ────────────────────----/**-- * Check if a chatter has ever chatted in this streamer's channel.-- * @param {string} chatterKey - e.g. "user:42" or "anon:anon3" or "ext:[Twitch] foo"-- * @param {number} channelUserId - the streamer's user ID-- * @returns {boolean} true if this is their first time-- */--function isFirstChatInChannel(chatterKey, channelUserId) {-- const row = get(-- 'SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',-- [chatterKey, channelUserId]-- );-- return !row;--}----/**-- * Record that a chatter has chatted in a streamer's channel.-- */--function recordFirstChat(chatterKey, channelUserId) {-- run(-- 'INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)',-- [chatterKey, channelUserId]-- );--}----// ── Moderation Action Logging ────────────────────────────────----/**-- * Log a moderation action for auditing.-- * Used by canvas, chat moderation, bans, etc.-- *-- * A staff or channel-moderator action also goes to OpenVibe.Network's moderation audit log as-- * live.moderation.action (Contracts 0.46.0, ADR-022), written to the outbox in the same transaction-- * as the row. Someone tidying their own messages (self_*), configuring their own channel, or acting-- * on themselves is not moderation and stays local.-- */--function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {-- const d = getDb();-- const row = [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})];-- let announced = false;-- const info = d.transaction(() => {-- const r = d.prepare(`-- INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)-- VALUES (?, ?, ?, ?, ?, ?)-- `).run(...row);-- announced = _announceModerationAction(Number(r.lastInsertRowid), { scope_type: row[0], scope_id: row[1], actor_user_id: row[2], target_user_id: row[3], action_type, details });-- return r;-- })();-- if (announced) require('../events/stream-events').kick();-- return info;--}----function _announceModerationAction(id, a, { at = null } = {}) {-- const type = String(a.action_type || '');-- if (!type || type.startsWith('self_') || type === 'channel_settings_update') return false;-- if (a.actor_user_id != null && a.actor_user_id === a.target_user_id) return false;-- const events = require('../events/stream-events');-- if (!events.status().enabled) return false;-- const subjectOf = (uid) => {-- if (uid == null) return null;-- const r = getDb().prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(uid);-- return r && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(r.subject_id || '') ? r.subject_id : null;-- };-- const actorSubject = subjectOf(a.actor_user_id);-- const int = (v) => (v == null || v === '' || !Number.isSafeInteger(Number(v)) ? null : Number(v));-- const scopeId = a.scope_id == null || a.scope_id === '' ? null : (Number.isSafeInteger(Number(a.scope_id)) ? Number(a.scope_id) : String(a.scope_id).slice(0, 128));-- const details = a.details && typeof a.details === 'object' && !Array.isArray(a.details) ? a.details : {};-- events.enqueue({-- ...(at ? { timestamp: at } : {}),-- event_type: 'live.moderation.action',-- actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'live' },-- subject: { type: 'moderation_action', id: String(id) },-- visibility: 'internal',-- priority: 'important',-- payload: {-- action_id: id, action_type: type.slice(0, 64), scope_type: String(a.scope_type || 'site').slice(0, 32), scope_id: scopeId,-- actor_user_id: int(a.actor_user_id), actor_subject: actorSubject,-- target_user_id: int(a.target_user_id), target_subject: subjectOf(a.target_user_id),-- details,-- },-- });-- return true;--}----/**-- * Get moderation actions with optional filters.-- */--function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {-- const conditions = [];-- const params = [];-- const st = scopeType || scope_type;-- const si = scopeId || scope_id;-- if (st) { conditions.push('ma.scope_type = ?'); params.push(st); }-- if (si) { conditions.push('ma.scope_id = ?'); params.push(si); }-- if (actor_user_id) { conditions.push('ma.actor_user_id = ?'); params.push(actor_user_id); }-- if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }-- const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';-- params.push(limit, offset);-- return all(`-- SELECT ma.*, actor.username AS actor_username, target.username AS target_username-- FROM moderation_actions ma-- LEFT JOIN users actor ON ma.actor_user_id = actor.id-- LEFT JOIN users target ON ma.target_user_id = target.id-- ${where}-- ORDER BY ma.created_at DESC-- LIMIT ? OFFSET ?-- `, params);--}----/**-- * Get a single chat message by ID.-- */--function getChatMessageById(id) {-- return get('SELECT * FROM chat_messages WHERE id = ?', [id]);--}----/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */--function mergeChatMessageMetadata(id, patch) {-- if (!id || !patch || typeof patch !== 'object') return;-- const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);-- if (!row) return;-- let meta = {};-- try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }-- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};-- return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);--}----/**-- * Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it.-- */--function deleteChatMessage(id, deletedBy = null) {-- return run(-- 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?',-- [deletedBy, id]-- );--}----/**-- * Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.-- * Returns the list of deleted message IDs for real-time broadcast.-- */--function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {-- const condition = streamId-- ? 'user_id = ? AND stream_id = ? AND is_deleted = 0'-- : 'user_id = ? AND is_deleted = 0';-- const params = streamId ? [userId, streamId] : [userId];-- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);-- const ids = messages.map(m => m.id);-- if (ids.length === 0) return [];-- const placeholders = ids.map(() => '?').join(',');-- run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,-- [deletedBy, ...ids]-- );-- return ids;--}----/**-- * Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.-- */--function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {-- const condition = streamId-- ? 'anon_id = ? AND stream_id = ? AND is_deleted = 0'-- : 'anon_id = ? AND is_deleted = 0';-- const params = streamId ? [anonId, streamId] : [anonId];-- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);-- const ids = messages.map(m => m.id);-- if (ids.length === 0) return [];-- const placeholders = ids.map(() => '?').join(',');-- run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,-- [deletedBy, ...ids]-- );-- return ids;--}----/**-- * Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")-- */--function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {-- const condition = streamId-- ? 'username = ? AND stream_id = ? AND is_deleted = 0'-- : 'username = ? AND is_deleted = 0';-- const params = streamId ? [username, streamId] : [username];-- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);-- const ids = messages.map(m => m.id);-- if (ids.length === 0) return [];-- const placeholders = ids.map(() => '?').join(',');-- run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,-- [deletedBy, ...ids]-- );-- return ids;--}----function deleteExpiredChatMessages(limit = 500) {-- const rows = all(-- `SELECT id, stream_id-- FROM chat_messages-- WHERE is_deleted = 0-- AND auto_delete_at IS NOT NULL-- -- Compared raw, not through datetime(): wrapping the column in a function makes the-- -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the-- -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape-- -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.-- AND auto_delete_at <= CURRENT_TIMESTAMP-- ORDER BY auto_delete_at ASC-- LIMIT ?`,-- [Math.max(1, Number(limit) || 500)]-- );-- if (!rows.length) return [];---- const ids = rows.map(row => row.id);-- const placeholders = ids.map(() => '?').join(',');-- run(-- `UPDATE chat_messages-- SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP-- WHERE id IN (${placeholders})`,-- ids-- );-- return rows;--}--// ── Approved IPs (Anti-VPN Mode) ─────────────────────────────/**@@@ -6300,153 +5430,6 @@ function getApprovedIps(channelId, { limit = 100, offset = 0 } = {}) {);}--/**-- * Hold a message for IP approval.-- */--function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {-- return run(-- `INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)-- VALUES (?, ?, ?, ?, ?, ?, ?)`,-- [channelId, streamId, ip, userId || null, anonId || null, username, message]-- );--}----/**-- * Get pending messages for a channel.-- */--function getPendingIpMessages(channelId, { limit = 50 } = {}) {-- return all(-- `SELECT * FROM pending_ip_messages WHERE channel_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT ?`,-- [channelId, limit]-- );--}----/**-- * Approve or deny a pending IP message. If approved, auto-approve the IP too.-- */--function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {-- // Scoped to the channel the caller was authorised for, so a message id from another channel's-- // queue matches nothing.-- const scoped = channelId != null;-- const res = scoped-- ? run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])-- : run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);-- if (!res.changes) return;-- if (status === 'approved') {-- const msg = get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);-- if (msg) approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');-- }--}----/**-- * Bulk-approve all pending messages from a specific IP in a channel.-- */--function approveAllFromIp(channelId, ip, reviewedBy) {-- approveIp(channelId, ip, reviewedBy, 'manual');-- return run(-- "UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",-- [reviewedBy, channelId, ip]-- );--}----/**-- * Deny all pending messages from a specific IP in a channel.-- */--function denyAllFromIp(channelId, ip, reviewedBy) {-- return run(-- "UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",-- [reviewedBy, channelId, ip]-- );--}----// ── Hidden Relay Users ───────────────────────────────────────----/**-- * Hide or ban a relayed external user.-- */--function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {-- return run(-- `INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)-- VALUES (?, ?, ?, ?, ?, ?)`,-- [channelId || null, platform, externalUsername, action, reason || null, createdBy]-- );--}----/**-- * Check if a relayed user is hidden/banned.-- * Checks both channel-scoped and site-wide entries (channel_id IS NULL).-- */--function isRelayUserHidden(channelId, platform, externalUsername) {-- return !!get(-- `SELECT 1 FROM hidden_relay_users-- WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,-- [platform, externalUsername, channelId]-- );--}----/**-- * Unhide/unban a relayed external user.-- */--function unhideRelayUser(id) {-- return run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);--}----/**-- * Unhide/unban a relayed external user by identity (platform + external username +-- * channel). Scoped to a single relay identity — never touches a registered-- * openvibelive account of the same name. `channel_id IS ?` matches NULL safely.-- */--function unhideRelayUserByIdentity(channelId, platform, externalUsername) {-- return run(-- 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?',-- [platform, externalUsername, channelId || null]-- );--}----/**-- * Get hidden relay users for a channel (includes site-wide).-- */--function getHiddenRelayUsers(channelId, { limit = 100 } = {}) {-- return all(-- `SELECT hru.*, u.username as created_by_username-- FROM hidden_relay_users hru LEFT JOIN users u ON hru.created_by = u.id-- WHERE hru.channel_id = ? OR hru.channel_id IS NULL-- ORDER BY hru.created_at DESC LIMIT ?`,-- [channelId, limit]-- );--}----/**-- * Search chat messages within a specific channel's streams.-- */--function searchChannelChatMessages(channelId, { query, userId, limit = 50, offset = 0 } = {}) {-- const conditions = [-- 'cm.stream_id IN (SELECT id FROM streams WHERE channel_id = ?)',-- 'cm.is_deleted = 0',-- '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',-- ];-- const params = [channelId];-- if (query) {-- conditions.push('cm.message LIKE ?');-- params.push(`%${query}%`);-- }-- if (userId) {-- conditions.push('cm.user_id = ?');-- params.push(userId);-- }-- params.push(limit, offset);-- return {-- messages: all(`-- SELECT cm.*, u.username, u.display_name, u.avatar_url-- FROM chat_messages cm-- LEFT JOIN users u ON cm.user_id = u.id-- WHERE ${conditions.join(' AND ')}-- ORDER BY cm.created_at DESC-- LIMIT ? OFFSET ?-- `, params),-- };--}--// ── IP Tracking ──────────────────────────────────────────────/**@@@ -6822,16 +5805,6 @@ function getChannelAnalyticsSummary(userId, days) {};}--function getRecentChatActivity(streamId, minutes) {-- const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();-- const row = get(-- `SELECT COUNT(*) as cnt FROM chat_messages-- WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,-- [streamId, cutoff]-- );-- return row?.cnt || 0;--}--/* ── User Preferences (server-side settings sync) ─────────── */function getUserPreferences(userId) {@@@ -6850,72 +5823,6 @@ function saveUserPreferences(userId, chatSettings) {);}--/* ── Chat Log Management ──────────────────────────────────── */----// Time ranges (purge, its preview, the log filter, chat replay): the dashboard sends ISO instants--// ('…T…Z') and rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a--// range matched nothing on its first day and all of its last: a purge removed lines outside the range--// the streamer chose and kept the ones inside it (and VOD chat replay showed them). datetime(?) reads--// both forms as UTC. OpenVibe.Chat, which serves these routes with CHAT_AUTHORITY=chat, does the same.--function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {-- if (streamId) {-- return run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP-- WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,-- [deletedBy, streamId, fromTime, toTime]-- );-- }-- // Global chat (is_global = 1)-- return run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP-- WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,-- [deletedBy, fromTime, toTime]-- );--}----function countChatMessagesByTimeRange(streamId, fromTime, toTime) {-- let row;-- if (streamId) {-- row = get(-- `SELECT COUNT(*) as cnt FROM chat_messages-- WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,-- [streamId, fromTime, toTime]-- );-- } else {-- row = get(-- `SELECT COUNT(*) as cnt FROM chat_messages-- WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,-- [fromTime, toTime]-- );-- }-- return row?.cnt || 0;--}----function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {-- const conditions = [];-- const params = [];---- if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }-- if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }-- if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }-- if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }-- if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }-- if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }-- if (!includeDeleted) { conditions.push('is_deleted = 0'); }---- const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';-- const offset = (page - 1) * limit;---- const countRow = get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);-- const total = countRow?.total || 0;-- const rows = all(-- `SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,-- [...params, limit, offset]-- );---- return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };--}--/* ── API Tokens (Bot / Integration auth) ──────────────────── */function _hashToken(rawToken) {@@@ -7019,8 +5926,8 @@ module.exports = {getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,getVodAiState, getClipAiState, forgetMediaItem,scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,-- getDb, initDb, run, get, all, close, announceModerationAction: _announceModerationAction,-- mergeChatMessageMetadata, getTimelineSpeechSince,++ getDb, initDb, run, get, all, close,++ getTimelineSpeechSince,getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,@@@ -7045,7 +5952,6 @@ module.exports = {cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,-- getChatSpikeOffsets, getLiveChatBuckets, getRecentChatText,upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,// Homepage helpers@@@ -7074,10 +5980,7 @@ module.exports = {getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,getSubscriptionsDueRenewal,getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,-- // Chat-- saveChatMessage, searchChatMessages, getUserChatHistory, getChatSamplesInChannel,recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,-- getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,// ProfilesgetUserProfile, updateUserAvatar,getKickChannelCache, setKickChannelCache,@@@ -7115,8 +6018,6 @@ module.exports = {getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,// BansisUserBanned, isIpBanned, getIpBan, invalidateIpBanCache, forgiveBan,-- createChannelSound, setChannelSoundEmote, getChannelSounds, getChannelSoundByCommand, getChannelSoundById, renameChannelSoundCommand, updateChannelSoundEmoteRefs,-- countChannelSounds, countChannelSoundsByUploader, deleteChannelSound,getAiChatbotConfig, upsertAiChatbotConfig,getChannelAiConfig, upsertChannelAiConfig,createChannelAiBot, getChannelAiBot, getChannelAiBots, getChannelAiBotByUsername,@@@ -7125,42 +6026,26 @@ module.exports = {getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,saveVodTranscriptProgress, getVodTranscriptProgress,createAiViewerThread, getOpenAiViewerThreads, getRecentClosedAiViewerThreads, touchAiViewerThread, closeAiViewerThread, closeStaleAiViewerThreads, closeAllAiViewerThreads,-- addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats, getChannelChatSince, getMaxChatMessageIdForChannel,++ addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats,getState, setState, deleteState,// Verification KeyscreateVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,-- // Chat Replay-- getChatReplay,// Channel lookupgetChannelById,getUserTotalGameLevel, getLegacyGameProfile,// Anon IP MappingsgetOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,-- // Stream first chats (welcome messages)-- isFirstChatInChannel, recordFirstChat,-- // Moderation Action Logging-- logModerationAction, getModerationActions,-- getChatMessageById, deleteChatMessage, searchChannelChatMessages,-- deleteUserChatMessages, deleteAnonChatMessages, deleteRelayUserMessages, deleteExpiredChatMessages,// Approved IPs (Anti-VPN)isIpApproved, approveIp, revokeIpApproval, getApprovedIps,-- holdMessageForApproval, getPendingIpMessages, reviewPendingIpMessage,-- approveAllFromIp, denyAllFromIp,-- // Hidden Relay Users-- hideRelayUser, isRelayUserHidden, unhideRelayUser, unhideRelayUserByIdentity, getHiddenRelayUsers, recordRelayUser,-- getRelayUserByRowid, getRelayUserChatHistory,-- anonSubjectId, getAnonMeta, getAnonChatHistory,// IP TrackinglogIp, getIpsByUser, getUsersByIp, getLinkedAccounts, getLinkedAccountsByAnon,getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,// Stream AnalyticsinsertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,-- getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary, getRecentChatActivity,++ getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary,// User PreferencesgetUserPreferences, saveUserPreferences,-- // Chat Log Management-- deleteChatMessagesByTimeRange, countChatMessagesByTimeRange, getChatLogs,// API TokenscreateApiToken, listApiTokens, revokeApiToken, validateApiToken,};@@@ -187,6 +187,57 @@ const OPERATOR_MIGRATIONS = [db.exec('DROP TABLE IF EXISTS chat_bridge_outbox');},},++ {++ id: 'op_003_drop_chat_tables',++ // T3 final step. OpenVibe.Chat owns these twelve tables and has been their only writer since++ // the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept); Live's readers++ // and writers are gone (PRs #28–#32 and the release that carries this migration), and this++ // release no longer creates them. The N-1 release still runs SQL over them++ // (test/n-1.test.js shows it), so the contract step is operator-run: once N-1 is out of++ // rollback range, scripts/chat-tables-drop.js --apply takes an online backup and runs this.++ // Dropping a table removes its own indexes; the explicit DROP INDEX lines also clear index++ // names older rebuilds left behind. dm_participants/dm_messages go before dm_conversations++ // (their FK parent). No trigger was ever created on any of the twelve.++ up: (db) => {++ db.exec(`++ DROP INDEX IF EXISTS idx_chat_stream_id;++ DROP INDEX IF EXISTS idx_chat_timestamp;++ DROP INDEX IF EXISTS idx_chat_user_id;++ DROP INDEX IF EXISTS idx_chat_stream;++ DROP INDEX IF EXISTS idx_chat_user;++ DROP INDEX IF EXISTS idx_chat_channel_user_ts;++ DROP INDEX IF EXISTS idx_chat_stream_ts;++ DROP INDEX IF EXISTS idx_chat_autodelete;++ DROP INDEX IF EXISTS idx_chat_ts_deleted;++ DROP INDEX IF EXISTS idx_channel_sounds_owner;++ DROP INDEX IF EXISTS idx_channel_sounds_cmd;++ DROP INDEX IF EXISTS idx_mod_actions_created;++ DROP INDEX IF EXISTS idx_mod_actions_actor;++ DROP INDEX IF EXISTS idx_mod_actions_scope;++ DROP INDEX IF EXISTS idx_pending_ip_channel;++ DROP INDEX IF EXISTS idx_hidden_relay_channel;++ DROP INDEX IF EXISTS idx_sfc_channel;++ DROP INDEX IF EXISTS idx_dm_participants_conv;++ DROP INDEX IF EXISTS idx_dm_participants_user;++ DROP INDEX IF EXISTS idx_dm_messages_conv;++ DROP INDEX IF EXISTS idx_dm_messages_sender;++ DROP INDEX IF EXISTS idx_dm_blocks_blocker;++ DROP INDEX IF EXISTS idx_dm_blocks_blocked;++ DROP TABLE IF EXISTS dm_participants;++ DROP TABLE IF EXISTS dm_messages;++ DROP TABLE IF EXISTS dm_conversations;++ DROP TABLE IF EXISTS dm_blocks;++ DROP TABLE IF EXISTS chat_messages;++ DROP TABLE IF EXISTS tts_voice_overrides;++ DROP TABLE IF EXISTS channel_sounds;++ DROP TABLE IF EXISTS relay_users;++ DROP TABLE IF EXISTS hidden_relay_users;++ DROP TABLE IF EXISTS pending_ip_messages;++ DROP TABLE IF EXISTS stream_first_chats;++ DROP TABLE IF EXISTS moderation_actions;++ `);++ },++ },];const failures = new Map(); // id -> message, for this process@@@ -145,29 +145,6 @@ CREATE TABLE IF NOT EXISTS cameras (FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE);--CREATE TABLE IF NOT EXISTS chat_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- stream_id INTEGER,-- user_id INTEGER, -- NULL for anon-- anon_id TEXT, -- 'anon12345' format-- username TEXT,-- message TEXT NOT NULL,-- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts')),-- metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)-- is_global INTEGER DEFAULT 0,-- is_deleted INTEGER DEFAULT 0,-- is_filtered INTEGER DEFAULT 0,-- reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,-- source_platform TEXT,-- deleted_by INTEGER,-- deleted_at DATETIME,-- auto_delete_at DATETIME,-- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL--);--CREATE TABLE IF NOT EXISTS vibe_coding_sessions (id INTEGER PRIMARY KEY AUTOINCREMENT,managed_stream_id INTEGER NOT NULL,@@@ -485,9 +462,6 @@ CREATE INDEX IF NOT EXISTS idx_streams_user_id ON streams(user_id);CREATE INDEX IF NOT EXISTS idx_streams_is_live ON streams(is_live);CREATE INDEX IF NOT EXISTS idx_streams_channel_id ON streams(channel_id);CREATE INDEX IF NOT EXISTS idx_channels_user_id ON channels(user_id);--CREATE INDEX IF NOT EXISTS idx_chat_stream_id ON chat_messages(stream_id);--CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);--CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);CREATE INDEX IF NOT EXISTS idx_follows_streamer ON follows(streamer_id);CREATE INDEX IF NOT EXISTS idx_transactions_to_user ON transactions(to_user_id);CREATE INDEX IF NOT EXISTS idx_vods_user_id ON vods(user_id);@@@ -545,24 +519,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_content ON comments(content_type, contenCREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);--CREATE TABLE IF NOT EXISTS channel_sounds (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to-- command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased-- url TEXT NOT NULL, -- served path of the audio file on disk-- mime TEXT DEFAULT 'audio/mpeg',-- duration_seconds REAL DEFAULT 0,-- created_by INTEGER, -- uploader user id (NULL if removed user)-- created_by_name TEXT DEFAULT '',-- is_approved INTEGER DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- -- Multiple sounds may share a command; playback picks one at random.-- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id);--CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);---- ═══════════════════════════════════════════════════════════════-- OpenCoins (Channel Points — free loyalty currency)-- Earned by watching streams, chatting, etc. Global across all streams.@@@ -1188,9 +1188,7 @@ async function start() {cosmeticsModule.ensureTables();// Chat tag tables (read-only tags, server/chat/tags.js)require('./chat/tags').ensureTagTables();-- // Initialize DM tables-- const dm = require('./chat/dm');-- dm.ensureTables();++ // (Live used to create DM tables here; OpenVibe.Chat owns them, and Live keeps no copy.)// Migrate: add last_heartbeat column if missingtry { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }// Migrate: add theme_id to users table if missing@@@ -1655,7 +1653,6 @@ function startDrill() {db.initDb();cosmeticsModule.ensureTables();require('./chat/tags').ensureTagTables();-- require('./chat/dm').ensureTables();console.log(`[Drill] Database ready: ${paths.dbPath()}`);// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });@@@ -391,10 +391,9 @@ class ChatRelayService {const color = PLATFORM_COLORS[bridge.platform] || '#888';const prefixedUsername = `[${label}] ${username}`;-- // Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached-- // peek — this path must not wait on an HTTP call): the fresh list, else the last good one.-- // A cold cache fails open, so a Chat outage never drops relayed lines; Live's own frozen-- // table is consulted only when Live is not in chat mode.++ // Check if this relay user is hidden/banned. Chat's queue answers (a cached peek — this++ // path must not wait on an HTTP call): the fresh list, else the last good one. A cold cache++ // fails open, so a Chat outage never drops relayed lines; Live keeps no local copy.try {const stream = db.getStreamById(bridge.streamId);const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);@@@ -403,74 +402,11 @@ class ChatRelayService {}} catch { /* non-critical — allow message through on error */ }++ // Chat persists, broadcasts, records the first chat and reads it aloud in one call.++ // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of++ // the send must not stall it. The catch keeps a surprise rejection off the process.const delivery = require('../chat/chat-delivery');-- if (delivery.ingress()) {-- // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of-- // the send must not stall it. The catch keeps a surprise rejection off the process.-- return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });-- }---- // Record this relay user's activity; their first message becomes a join date.-- try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }---- const chatMsg = {-- type: 'chat',-- username: prefixedUsername,-- user_id: null,-- anon_id: null,-- role: 'external',-- message: String(message || ''),-- stream_id: bridge.streamId,-- is_global: false,-- avatar_url: extras.avatar_url || null,-- profile_color: color,-- timestamp: extras.timestamp || new Date().toISOString(),-- source_platform: bridge.platform,-- };---- try {-- const result = db.saveChatMessage({-- stream_id: bridge.streamId,-- user_id: null,-- anon_id: null,-- username: prefixedUsername,-- message: chatMsg.message,-- message_type: 'chat',-- is_global: 0,-- source_platform: bridge.platform,-- });-- if (result?.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);-- } catch {}---- 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 { delivery.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }-- // And to viewers of the streamer's other live slots (cross-slot chat)-- try { delivery.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }---- // Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS-- try {-- 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');---- // Welcome first-time external chatters in this streamer's channel-- try {-- const stream = db.getStreamById(bridge.streamId);-- if (stream?.user_id) {-- const chatterKey = `ext:${prefixedUsername}`;-- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {-- db.recordFirstChat(chatterKey, stream.user_id);-- delivery.broadcastToStream(bridge.streamId, {-- type: 'system',-- message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,-- timestamp: new Date().toISOString(),-- });-- }-- }-- } catch { /* non-critical */ }++ return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });}/** What follows a relayed line once it has an id: AI viewers hear it, PowerChat's overlay shows it. */@@@ -91,13 +91,10 @@ 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({ frame, line, mirror = false, key }) {-- if (delivery.ingress()) { delivery.message({ ...line, mirror, key }); return; }-- 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);++// A persisted chat line: one Chat ingress call (Chat persists and shows it; `mirror` also puts it++// in global chat). Live keeps no chat table.++function _chatLine({ line, mirror = false, key }) {++ delivery.message({ ...line, mirror, key });}function _handleDonation(userId, data) {@@@ -111,7 +108,6 @@ function _handleDonation(userId, data) {const donor = String(data.donorName || 'Someone').slice(0, 80);const message = String(data.message || '').slice(0, 500);const goalId = _goalIdFromDonation(data);-- const ts = new Date().toISOString();// If the streamer is live, attach to the live session so it lands in that slot too.let streamId = null;@@@ -121,15 +117,11 @@ function _handleDonation(userId, data) {let goalResult = null;try { goalResult = openvibeBucks.applyDonationToGoal(userId, amount, goalId); } catch { /* */ }-- // 1) Donation chat event — live + persisted to channel history.-- const donationEvent = {-- type: 'donation', username: donor, user_id: null, avatar_url: null,-- amount, message, source: 'powerchat', timestamp: ts,-- };++ // 1) Donation chat event — persisted to channel history (and mirrored to global chat).const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;try {_chatLine({-- frame: donationEvent, mirror: true, key: eventKey,++ mirror: true, key: eventKey,line: {stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,message: `${donor} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''} (PowerChat)`,@@@ -150,7 +142,6 @@ function _handleDonation(userId, data) {const g = goalResult.goal;try {_chatLine({-- frame: { type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts },key: eventKey && `${eventKey}:goal`,line: {stream_id: streamId, channel_user_id: userId, user_id: null, username: 'Donation Goal',@@@ -169,10 +160,8 @@ function _handleDonation(userId, data) {// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).function _handleSubscription(userId, data) {const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);-- const ts = new Date().toISOString();try {_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: {stream_id: null, channel_user_id: userId, user_id: null, username: name,@@@ -186,9 +175,7 @@ function _handleSubscription(userId, data) {// A lightweight chat notice for non-money events (follow / host / points redeem).function _handleNotice(userId, message, kind) {try {-- 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',message, message_type: 'system', metadata: { kind: kind || 'powerchat', source: 'powerchat' },@@@ -291,18 +278,13 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messaglet streamId = null;try { const live = db.getLiveStreamsByUserId(userId) || []; if (live.length) streamId = live[0].id; } catch { /* */ }-- const testEvent = {-- type: 'donation', username: donor, user_id: null, avatar_url: null,-- amount, message, source: 'powerchat-test', timestamp: ts,-- };// Not to global chat: a test tip is the streamer's own preview, and any signed-in user could// otherwise announce a fake 99,900-Vibe donation site-wide.// Persist it, exactly like a real tip. A test that vanishes on reload does not// actually test what the streamer is checking — that the alert lands in chat AND-- // survives a refresh. This was the only donation path that broadcast without saving.++ // survives a refresh.try {_chatLine({-- frame: testEvent,line: {stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,message: `${donor} donated ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`,@@@ -648,15 +648,11 @@ class RobotStreamerService {// Drop RS protocol/meta messages (e.g. {"user_name":"Goosely"})if (/^\s*\{.*\}\s*$/.test(msgText)) return;-- const timestamp = Number.isFinite(Number(data.timestamp))-- ? new Date(Number(data.timestamp)).toISOString()-- : new Date().toISOString();const rawUsername = String(data.username || 'anon');const username = `[RS] ${rawUsername}`;-- // Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached-- // peek — fresh list, else the last good one; a cold cache fails open), Live's own-- // table only when Live is not in chat mode.++ // Check if this relay user is hidden/banned: Chat's queue (a cached peek — fresh++ // list, else the last good one; a cold cache fails open). Live keeps no local copy.try {const channel = stream.channel_id? db.getChannelById(stream.channel_id)@@@ -668,8 +664,7 @@ class RobotStreamerService {// Record this relay user (first message = join date) so RobotStreamer// chatters get the same chat logs + AI insight as other relay users.-- if (delivery.ingress()) { delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }-- else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }++ delivery.moderate('relay-record', { platform: 'rs', username: rawUsername });// Let RobotStreamer viewers trigger channel !sound commands too. If the// message is a registered !sound, play it (attributed to the RS user) and@@@ -681,77 +676,22 @@ class RobotStreamerService {// Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own// frozen table is never a fallback — null means no sound (or Chat unreachable).if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {-- if (delivery.ingress()) {-- delivery.event({ kind: 'stream', id: stream.id }, {-- type: 'channel-sound', streamId: stream.id, command: scmd, args: parts.slice(1).map((a) => a.slice(0, 120)).slice(0, 20),-- relay: { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' },-- });-- return;-- }-- try {-- delivery.triggerChannelSound(-- null,-- { streamId: stream.id, user: null, anonId: null, ip: null },-- stream, scmd, parts.slice(1),-- { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' }-- );-- } catch { /* non-critical */ }++ // Chat plays the sound (it owns channel_sounds); Live sends the trigger.++ delivery.event({ kind: 'stream', id: stream.id }, {++ type: 'channel-sound', streamId: stream.id, command: scmd, args: parts.slice(1).map((a) => a.slice(0, 120)).slice(0, 20),++ relay: { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' },++ });return;}}-- const mirrored = {-- type: 'chat',-- username,-- user_id: null,-- anon_id: null,-- role: 'external',-- message: msgText,-- stream_id: stream.id,-- is_global: false,-- avatar_url: data.avatar || null,-- profile_color: '#7dd3fc',-- timestamp,-- source_platform: 'rs',-- };---- if (delivery.ingress()) {-- // Chat persists, broadcasts, mirrors and reads it aloud in one call.-- delivery.after(delivery.message({-- stream_id: stream.id, username, message: msgText, message_type: 'chat', is_global: false,-- source_platform: 'rs', mirror: true,-- frame: { role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || undefined },-- tts: { identity_key: `rs:${username}` },-- }), (id) => rsFollowUps(stream, username, rawUsername, msgText, data.avatar, id));-- return;-- }-- try {-- const result = db.saveChatMessage({-- stream_id: stream.id,-- user_id: null,-- anon_id: null,-- username,-- message: mirrored.message,-- message_type: 'chat',-- is_global: 0,-- source_platform: 'rs',-- });-- if (result?.lastInsertRowid) mirrored.id = Number(result.lastInsertRowid);-- } catch {}---- 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 { delivery.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }-- // And to viewers of the streamer's other live slots (cross-slot chat)-- try { delivery.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }---- // Feed relayed RS chat into server-side TTS (same path as native chat)-- try {-- 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');++ // Chat persists, broadcasts, mirrors and reads it aloud in one call.++ delivery.after(delivery.message({++ stream_id: stream.id, username, message: msgText, message_type: 'chat', is_global: false,++ source_platform: 'rs', mirror: true,++ frame: { role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || undefined },++ tts: { identity_key: `rs:${username}` },++ }), (id) => rsFollowUps(stream, username, rawUsername, msgText, data.avatar, id));});bridge.ws.on('close', () => {@@@ -1,11 +1,12 @@/**-- * asset-sync.js — mirror Live's channel sounds into OpenVibe.Media, their canonical public home-- * (served at /a/:id, browsable on the media index with uploader + channel attribution).++ * asset-sync.js — mirror OpenVibe.Chat's channel sounds into OpenVibe.Media, their canonical public++ * home (served at /a/:id, browsable on the media index with uploader + channel attribution).*-- * Local files remain the low-latency working copy (chat playback reads disk); this job uploads-- * anything Media doesn't have yet and records media_url / media_asset_id on the local row, so-- * sound URLs come from Media's long-cached endpoint and deletes propagate. Idempotent: rows are-- * skipped once media_asset_id is set, and Media upserts on (kind, name, channel).++ * Chat owns the channel_sounds rows; this job asks its pending-asset read for anything Media++ * doesn't have yet, uploads the local file, and records media_url / media_asset_id back through++ * Chat's sound-asset write, so sound URLs come from Media's long-cached endpoint and deletes++ * propagate. Idempotent: rows are skipped once media_asset_id is set, and Media upserts on++ * (kind, name, channel).** The emote backfill went with roadmap T3: OpenVibe.Chat owns `emotes` and uploads emote bytes to* Media with its own token, so Live no longer has emote rows to sync.@@@ -26,14 +27,6 @@ const MIME_BY_EXT = {'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.webm': 'audio/webm',};--function _ensureColumns() {-- for (const [table, col, type] of [-- ['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],-- ]) {-- try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }-- }--}--function _localFile(dir, urlOrPath) {if (!urlOrPath) return null;if (path.isAbsolute(urlOrPath) && fs.existsSync(urlOrPath)) return urlOrPath;@@@ -57,7 +50,6 @@ async function syncAll() {if (_running) return;_running = true;try {-- _ensureColumns();const nameCache = {};const uname = (id) => {if (!id) return '';@@@ -66,9 +58,9 @@ async function syncAll() {};let synced = 0, failed = 0;-- // The work list is Chat's (channel_sounds is Chat's table); Live's own rows when Live runs-- // chat. Page by id until a short page: sounds with no local file stay pending and would-- // otherwise fill the first window forever, starving every later sound.++ // The work list is Chat's (it owns channel_sounds). Page by id until a short page: sounds++ // with no local file stay pending and would otherwise fill the first window forever,++ // starving every later sound.const PAGE = 100;let afterId = 0;for (;;) {@@@ -55,18 +55,6 @@ async function _sendOne(clipId) {creator_color: (creator && creator.profile_color) || null,auto: !!clip.auto_generated,};-- const payload = {-- type: 'chat',-- message_type: 'clip',-- username: creatorName,-- user_id: null,-- message: `clipped: ${title}`,-- stream_id: streamId,-- channel_user_id: ownerId,-- is_global: false,-- clip: meta,-- timestamp: new Date().toISOString(),-- };const line = {stream_id: streamId,channel_user_id: ownerId,@@@ -78,15 +66,8 @@ async function _sendOne(clipId) {};try {const delivery = require('../chat/chat-delivery');-- if (delivery.ingress()) {-- // Chat persists and shows the line (one key per clip, so a re-announce cannot post twice).-- await delivery.message({ ...line, key: `clip:${clipId}` });-- db.markClipNotifiedState(clipId);-- return;-- }-- const saved = db.saveChatMessage(line);-- payload.id = saved && saved.lastInsertRowid;-- delivery.broadcastToChannelRoom(ownerId, streamId, payload);++ // Chat persists and shows the line (one key per clip, so a re-announce cannot post twice).++ await delivery.message({ ...line, key: `clip:${clipId}` });} catch (e) {console.warn('[ClipNotify] send failed:', e.message);}@@@ -91,15 +91,9 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {const alerts = require('./alerts');const donorUser = db.getUserById(req.user.id);const donor = donorUser?.display_name || donorUser?.username || 'Someone';-- const ts = new Date().toISOString();-- // 1) Donation chat message — broadcast live AND persist to channel history so-- // late-joiners see it. Channel-room broadcast reaches all slots + offline.-- const donationEvent = {-- type: 'donation', username: donor, user_id: req.user.id,-- avatar_url: donorUser?.avatar_url || null,-- amount: result.amount, message: message || '', timestamp: ts,-- };++ // 1) Donation chat message — Chat persists it to channel history so late-joiners see++ // it, and mirrors it into global chat.const donationLine = {stream_id: stream_id || null, channel_user_id: streamer_id, user_id: req.user.id,username: donor,@@@ -107,16 +101,8 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {message_type: 'donation',metadata: { kind: 'donation', amount: result.amount, message: message || '', username: donor, user_id: req.user.id, avatar_url: donorUser?.avatar_url || null },};-- if (delivery.ingress()) {-- // 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 {-- 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 { delivery.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }-- try { db.saveChatMessage(donationLine); } catch { /* non-critical */ }-- }++ // 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 });// 2) Donation sound (streamer-configured).alerts.playAlertSound(streamer_id, stream_id, 'donation');@@@ -149,13 +135,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {message_type: 'donation',metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },};-- if (delivery.ingress()) delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });-- else {-- delivery.broadcastToChannelRoom(streamer_id, stream_id || null, {-- type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts,-- });-- try { db.saveChatMessage(goalLine); } catch { /* non-critical */ }-- }++ delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });alerts.playAlertSound(streamer_id, stream_id, 'goal');}@@@ -131,16 +131,8 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limmessage_type: 'donation',metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },};-- let chatMessageId = null;-- if (delivery.ingress()) {-- // 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 {-- 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;-- }++ // Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.++ const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));}@@@ -2,9 +2,10 @@// Account export and deletion → Live (roadmap WS-B task 7, ADR-033): through POST /internal/network-events (signed),// network.account.export_requested sends Live's part to Network (the profile and the person's rows per table, no// stream keys or token hashes; an empty part for someone with no Live account), and network.account.deleted erases the--// person's rows (follows and channel points both ways, streams with what is inside them, their own chat lines,--// managed streams, tokens), keeps money and moderation rows pointing at a tombstone (the donation message cleared),--// keeps shared rows without them, releases the username, and confirms with counts. A redelivery sends nothing twice;++// person's rows (follows and channel points both ways, streams with what is inside them, managed streams, tokens),++// keeps money rows pointing at a tombstone (the donation message cleared), keeps shared rows without them, releases++// the username, and confirms with counts. (The chat tables are OpenVibe.Chat's and erased by its own eraser.)++// A redelivery sends nothing twice;// a failed confirmation is retried without erasing again. The subscription asks for both topics.// node test/account-data.test.jsconst assert = require('assert');@@@ -37,13 +38,16 @@ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, servid.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (20, 21), (22, 20), (22, 21)').run();d.prepare('INSERT INTO channel_points (user_id, streamer_id, balance) VALUES (20, 21, 30), (22, 20, 7), (22, 21, 5)').run();d.prepare("INSERT INTO streams (id, user_id, title) VALUES (1, 20, 'dana live'), (2, 21, 'xena live')").run();--// yuri chats in dana's stream (goes with the stream); dana chats in xena's (her own line goes); yuri in xena's stays.--d.prepare("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, 22, 'yuri', 'hey dana'), (2, 20, 'dana', 'hey xena'), (2, 22, 'yuri', 'yo')").run();d.prepare("INSERT INTO managed_streams (user_id, stream_key) VALUES (20, 'sk_live_secret_dana')").run();d.prepare("INSERT INTO api_tokens (user_id, token_hash) VALUES (20, 'hash-secret-dana')").run();d.prepare("INSERT INTO transactions (from_user_id, to_user_id, amount, type, message) VALUES (20, 21, 5, 'donation', 'love you xena'), (22, 20, 3, 'donation', 'for dana')").run();d.prepare("INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, status) VALUES (20, 'paypal', 'PO-1', 'bucks', 500, 'USD', 5, 'completed')").run();--d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id) VALUES ('timeout', 21, 20)").run();++// A database migrated before op_003 still holds Live's legacy copies of Chat's tables (a fresh schema creates none):++// a deletion must erase the person from them while they exist.++d.exec(`CREATE TABLE IF NOT EXISTS chat_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, username TEXT, message TEXT);++ CREATE TABLE IF NOT EXISTS pending_ip_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, ip TEXT, message TEXT);`);++d.prepare("INSERT INTO chat_messages (user_id, username, message) VALUES (20, 'dana', 'hello from dana'), (21, 'xena', 'hi')").run();++d.prepare("INSERT INTO pending_ip_messages (user_id, ip, message) VALUES (20, '203.0.113.20', 'held line')").run();const envelope = (type, payload) => {assert.ok(validate(`${type}@1`, payload).valid, JSON.stringify(validate(`${type}@1`, payload).errors));@@@ -105,12 +109,12 @@ const ulid = () => ids.ulid();assert.strictEqual(n('SELECT COUNT(*) AS n FROM channel_points WHERE user_id = 20 OR streamer_id = 20'), 0, 'channel points both ways');assert.strictEqual(n('SELECT COUNT(*) AS n FROM follows'), 1, "others' follows stay");assert.strictEqual(n('SELECT COUNT(*) AS n FROM streams WHERE user_id = 20'), 0);-- assert.deepStrictEqual(d.prepare('SELECT stream_id, user_id, message FROM chat_messages ORDER BY id').all(), [{ stream_id: 2, user_id: 22, message: 'yo' }],-- 'the stream took its chat with it; her own line elsewhere went; others stay');assert.strictEqual(n('SELECT COUNT(*) AS n FROM managed_streams WHERE user_id = 20') + n('SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = 20') + n("SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = 20"), 0);assert.deepStrictEqual(d.prepare('SELECT from_user_id AS f, message FROM transactions ORDER BY id').all(), [{ f: 20, message: null }, { f: 22, message: 'for dana' }], 'money kept; her own message cleared');assert.strictEqual(n("SELECT COUNT(*) AS n FROM payment_orders WHERE user_id = 20"), 1, 'payment orders kept');-- assert.strictEqual(n('SELECT COUNT(*) AS n FROM moderation_actions WHERE target_user_id = 20'), 1, 'moderation records kept');++ assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 20 OR username = \'dana\''), 0, 'her lines are erased from the legacy chat copy');++ assert.strictEqual(n('SELECT COUNT(*) AS n FROM pending_ip_messages WHERE user_id = 20'), 0, 'and her held line with its IP');++ assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 21'), 1, "others' lines stay");const tomb = d.prepare('SELECT username, email, bio, deleted_at FROM users WHERE id = 20').get();assert.deepStrictEqual([tomb.username, tomb.email, tomb.bio, !!tomb.deleted_at], ['deleted-20', null, null, true]);d.prepare("INSERT INTO users (username, password_hash) VALUES ('dana', '$sso$')").run();@@@ -119,7 +123,7 @@ const ulid = () => ids.ulid();assert.strictEqual(conf.path, `/internal/account-deletions/${del.payload.deletion_id}/confirmations`);assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));assert.ok(conf.body.erased.follows >= 2 && conf.body.erased.streams === 1 && conf.body.erased.accounts === 1, JSON.stringify(conf.body.erased));-- assert.ok(conf.body.retained.transactions === 2 && conf.body.retained.payment_orders === 1 && conf.body.retained.moderation_actions === 1, JSON.stringify(conf.body.retained));++ assert.ok(conf.body.retained.transactions === 2 && conf.body.retained.payment_orders === 1, JSON.stringify(conf.body.retained));assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');assert.strictEqual(sent.length, 3);@@@ -49,7 +49,19 @@ for (const id of [3, 4]) db.ensureChannel(id);const chanA = db.getChannelByUserId(3), chanB = db.getChannelByUserId(4);const streamA = db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid;const streamB = db.createStream({ user_id: 4, channel_id: chanB.id, title: 'B', protocol: 'webrtc' }).lastInsertRowid;--const msgB = db.saveChatMessage({ stream_id: streamB, channel_user_id: 4, user_id: 5, username: 'carol', message: 'hello bob' }).lastInsertRowid;++++// OpenVibe.Chat owns the chat tables now (Live keeps no copy), so the permission checks that used++// to read a local row go through the seam: stub Chat's message/relay reads and the moderation++// write, and keep everything else production code.++const MSG_B = 900100;++let RELAY_ROW = null;++const moderated = [];++const chatReads = require('../server/chat/chat-reads');++const delivery = require('../server/chat/chat-delivery');++chatReads.messageById = async (id) => (Number(id) === MSG_B ? { id: MSG_B, stream_id: Number(streamB), channel_user_id: 4, user_id: 5 } : null);++chatReads.channelSamples = async (channelUserId, { userId } = {}) => (Number(channelUserId) === 4 && Number(userId) === 5 ? [{ id: 1, username: 'carol', message: 'only in bob' }] : []);++chatReads.relayUser = async (id) => (RELAY_ROW && Number(id) === RELAY_ROW.id ? RELAY_ROW : null);++delivery.moderate = async (action, fields) => { moderated.push({ action, fields }); return { ids: fields && fields.id ? [Number(fields.id)] : [] }; };const express = require('express');const app = express();@@@ -86,13 +98,14 @@ async function check(name, fn) {await new Promise((r) => server.once('listening', r));await check('chat: owning stream A does not let you delete a message in stream B', async () => {-- const r = await call('POST', '/api/mod/delete-message', 3, { message_id: msgB, stream_id: streamA });++ const r = await call('POST', '/api/mod/delete-message', 3, { message_id: MSG_B, stream_id: streamA });assert.strictEqual(r.status, 403, r.text);-- assert.strictEqual(db.getChatMessageById(msgB).is_deleted, 0);++ assert.ok(!moderated.some((m) => m.action === 'delete-message'), 'nothing reached Chat');});await check('chat: the owner of stream B can delete it', async () => {-- const r = await call('POST', '/api/mod/delete-message', 4, { message_id: msgB });++ const r = await call('POST', '/api/mod/delete-message', 4, { message_id: MSG_B });assert.strictEqual(r.status, 200, r.text);++ assert.ok(moderated.some((m) => m.action === 'delete-message' && m.fields.id === MSG_B), 'Chat is asked to delete it');});await check('controls: a button id from someone else\'s profile cannot be edited or deleted through yours', async () => {@@@ -129,10 +142,9 @@ async function check(name, fn) {await check('relay users: a plain user cannot hide site-wide or remove another channel\'s hide', async () => {assert.strictEqual((await call('POST', '/api/mod/relay-user/hide', 5, { platform: 'twitch', external_username: 'x' })).status, 403);-- db.hideRelayUser({ channelId: chanB.id, platform: 'twitch', externalUsername: 'troll', action: 'ban', createdBy: 4 });-- const row = raw.prepare('SELECT id FROM hidden_relay_users WHERE channel_id = ?').get(chanB.id);-- assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${row.id}`, 3)).status, 403);-- assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${row.id}`, 4)).status, 200);++ RELAY_ROW = { id: 900200, channel_id: chanB.id, platform: 'twitch', external_username: 'troll' };++ assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${RELAY_ROW.id}`, 3)).status, 403);++ assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${RELAY_ROW.id}`, 4)).status, 200);});await check('IP approval: owning stream #N does not open channel #N\'s queue', async () => {@@@ -167,9 +179,10 @@ async function check(name, fn) {});await check("AI viewer clone: a streamer cannot copy someone's chat from other channels", async () => {-- db.saveChatMessage({ stream_id: streamB, channel_user_id: 4, user_id: 5, username: 'carol', message: 'only in bob' });-- assert.strictEqual(db.getChatSamplesInChannel(4, { userId: 5 }).length, 1);-- assert.strictEqual(db.getChatSamplesInChannel(3, { userId: 5 }).length, 0);++ // Chat scopes the samples to the asking streamer's channel (stubbed): bob's channel has++ // carol's line, alice's has none, so alice's clone finds nothing to copy.++ assert.strictEqual((await chatReads.channelSamples(4, { userId: 5 })).length, 1);++ assert.strictEqual((await chatReads.channelSamples(3, { userId: 5 })).length, 0);const r = await call('POST', '/api/ai-viewers/clone', 3, { kind: 'user', ref: 5 });assert.strictEqual(r.status, 404, r.text);});@@@ -136,9 +136,8 @@ async function check(name, fn) {assert.strictEqual(billing.payable[SID.bob], 150);const row = raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);-- // The chat celebration (display) is still Live's.-- const msg = raw.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation' ORDER BY id DESC LIMIT 1").get();-- assert.ok(msg && /donated 150 Vibes/.test(msg.message));++ // The chat celebration is OpenVibe.Chat's now: Live sends it the line through the delivery++ // seam and keeps no copy (test/tips-delivery.test.js covers the payload).});await check('a browser retry with the same Idempotency-Key is one donation', async () => {@@@ -227,10 +227,10 @@ const waitFor = async (pred, what, ms = 3000) => {const db = require('../server/db/database');db.initDb();-- require('../server/chat/dm').ensureTables();const d = db.getDb();-- // Live no longer has the staged chat tables (dropped in T3 N+2); the stub Chat below imitates-- // Chat's own copy, so the test keeps local copies to seed and read.++ // Live no longer has any of OpenVibe.Chat's tables (the staged ones dropped in T3 N+2; the++ // twelve chat tables dropped by op_003). The stub Chat below imitates Chat's own copy, so the++ // test keeps local stand-in tables to seed and read.d.exec(`CREATE TABLE IF NOT EXISTS channel_moderators (id INTEGER PRIMARY KEY AUTOINCREMENT,@@@ -250,6 +250,38 @@ const waitFor = async (pred, what, ms = 3000) => {user_id INTEGER NOT NULL,channel_owner_id INTEGER);++ CREATE TABLE IF NOT EXISTS chat_messages (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ stream_id INTEGER, channel_user_id INTEGER, user_id INTEGER, anon_id TEXT, username TEXT,++ message TEXT, message_type TEXT DEFAULT 'chat', is_global INTEGER DEFAULT 0,++ is_deleted INTEGER DEFAULT 0, source_platform TEXT, metadata TEXT,++ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, auto_delete_at DATETIME++ );++ CREATE TABLE IF NOT EXISTS stream_first_chats (++ chatter_key TEXT NOT NULL, channel_user_id INTEGER NOT NULL,++ first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (chatter_key, channel_user_id)++ );++ CREATE TABLE IF NOT EXISTS hidden_relay_users (++ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER, platform TEXT NOT NULL,++ external_username TEXT NOT NULL, action TEXT DEFAULT 'hide', reason TEXT, created_by INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++ CREATE TABLE IF NOT EXISTS tts_voice_overrides (++ identity_key TEXT PRIMARY KEY, voice TEXT, pitch INTEGER, speed INTEGER, gap INTEGER DEFAULT 0,++ set_by INTEGER, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++ CREATE TABLE IF NOT EXISTS channel_sounds (++ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_owner_id INTEGER NOT NULL, command TEXT NOT NULL,++ url TEXT NOT NULL, mime TEXT DEFAULT 'audio/mpeg', duration_seconds REAL DEFAULT 0,++ created_by INTEGER, created_by_name TEXT DEFAULT '', is_approved INTEGER DEFAULT 1,++ emote_code TEXT DEFAULT '', media_url TEXT, media_asset_id INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );++ CREATE TABLE IF NOT EXISTS pending_ip_messages (++ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL, stream_id INTEGER,++ ip_address TEXT NOT NULL, user_id INTEGER, anon_id TEXT, username TEXT, message TEXT NOT NULL,++ status TEXT DEFAULT 'pending', reviewed_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ );`);const mkUser = (username, role = 'user', extra = {}) => {const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;@@@ -448,17 +480,18 @@ const waitFor = async (pred, what, ms = 3000) => {const chatReads = require('../server/chat/chat-reads');const d2 = require('../server/db/database');chatReads._reset();-- for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }-- // Chat's copy (the stub reads Live's tables): two lines by the same user, a held IP++ // Chat's copy (the stub reads the stand-in tables): two lines by the same user, a held IP// message, a hidden relay user, a TTS override, two pending sounds and a relayed line.-- const m1 = Number(d2.saveChatMessage({ user_id: viewer, username: 'viewer', message: 'hello', channel_user_id: streamer, message_type: 'chat' }).lastInsertRowid);-- d2.saveChatMessage({ user_id: viewer, username: 'viewer', message: 'again', channel_user_id: streamer, message_type: 'chat' });++ const chatMsg = (fields) => Number(d.prepare(`INSERT INTO chat_messages (user_id, username, message, channel_user_id, source_platform, message_type)++ VALUES (@user_id, @username, @message, @channel_user_id, @source_platform, @message_type)`).run({ user_id: null, channel_user_id: null, source_platform: null, message_type: 'chat', ...fields }).lastInsertRowid);++ const m1 = chatMsg({ user_id: viewer, username: 'viewer', message: 'hello', channel_user_id: streamer });++ chatMsg({ user_id: viewer, username: 'viewer', message: 'again', channel_user_id: streamer });d2.run("INSERT INTO pending_ip_messages (channel_id, ip_address, user_id, username, message, status) VALUES (?, '203.0.113.9', ?, 'viewer', 'held', 'pending')", [channel.id, viewer]);d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)", [channel.id, streamer]);d2.run("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:viewer', 'en+f3', 99, 200, 0, ?)", [admin]);d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/a.mp3', ?), (?, 'beep', '/sounds/b.mp3', ?)", [streamer, streamer, streamer, streamer]);-- d2.saveChatMessage({ username: '[Twitch] alice', message: 'hi from twitch', source_platform: 'twitch', channel_user_id: streamer, message_type: 'chat' });++ chatMsg({ username: '[Twitch] alice', message: 'hi from twitch', source_platform: 'twitch', channel_user_id: streamer });// (2)(3) A peek answers the last good value after its TTL passed, and a failure answers// that value too — a failure never overwrites it with null.@@@ -627,15 +660,16 @@ const waitFor = async (pred, what, ms = 3000) => {readState.down = false;chatReads._reset();-- // (5) The home-stats peek's local (rollback) answer is a messages-only count.++ // (5) The home-stats peek outside chat mode: a messages-only zero (Live keeps no++ // chat_messages since the drop, so there is nothing local to count).process.env.CHAT_AUTHORITY = '';try {chatReads._reset();const peeked = chatReads.siteStatsPeek();assert.strictEqual(peeked.chatters, undefined, 'the peek does not run the distinct-chatter scan');-- assert.ok(peeked.messages >= 2, 'the peek still counts the messages');++ assert.strictEqual(peeked.messages, 0, 'no local messages to count');const full = await chatReads.siteStats();-- assert.ok(full.chatters >= 1, 'the full stats read still counts chatters');++ assert.strictEqual(full.messages, 0, 'the full local read is zero too');} finally {process.env.CHAT_AUTHORITY = 'chat';chatReads._reset();@@@ -735,8 +769,8 @@ const waitFor = async (pred, what, ms = 3000) => {chatReads._reset();}-- // (3) Live's own hidden copy is frozen and dead weight in chat mode: a stale local hide-- // that Chat's list does not carry must not hide anyone (the mirror no longer keeps it in step).++ // (3) A stale local row is dead weight in chat mode: a hide Chat's list does not carry++ // must not hide anyone (the stand-in row is never read).{const chatClient = require('../server/chat/chat-client');const realReadRelayUsers = chatClient.readRelayUsers;@@@ -746,10 +780,8 @@ const waitFor = async (pred, what, ms = 3000) => {chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothingchatReads._reset();try {-- assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'no local row yet');++ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open');d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);-- assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own row is there");-- assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open, never Live\'s row');for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);assert.ok(chatReads._size() > 0, 'the background read warmed the cache');assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");@@@ -805,6 +837,7 @@ const waitFor = async (pred, what, ms = 3000) => {[viewer, atDay(2), viewer, atDay(2), mod, atDay(2)]);d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, timestamp) VALUES (?, 'viewer', 'd', 'chat', ?), (?, 'moddy', 'e', 'chat', ?)",[viewer, atDay(1), mod, atDay(1)]);++ await chatReads.homeSeries('messages', 7); // warm Chat's answer; the peek cannot awaitconst series = chatReads.homeSeriesPeek('messages', 7);assert.ok(series && Array.isArray(series.points), 'the chat series peek answers in chat mode');assert.strictEqual(series.days, 7);@@@ -815,6 +848,7 @@ const waitFor = async (pred, what, ms = 3000) => {assert.strictEqual(series.total, 5);assert.strictEqual(series.prev_total, 0, 'the window before the 7 days is empty');assert.strictEqual(series.before, 0, 'nothing before the window');++ await chatReads.homeSeries('active', 7); // warm Chat's answerconst activeSeries = chatReads.homeSeriesPeek('active', 7);assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');@@@ -823,12 +857,10 @@ const waitFor = async (pred, what, ms = 3000) => {chatReads.homeSeriesPeek = () => ({ metric: 'messages', sentinel: true });try { assert.strictEqual(d2.getHomeStatSeries('messages', 7).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode'); }finally { chatReads.homeSeriesPeek = realSeriesPeek; }-- // A cold cache during a Chat outage answers Live's own table, not zeros.++ // A cold cache during a Chat outage answers null (Live keeps no chat series to fall back to).chatReads._reset();readState.down = true;-- const fallbackSeries = chatReads.homeSeriesPeek('messages', 7);-- assert.ok(fallbackSeries && fallbackSeries.points.length === 7, 'a cold + down peek answers Live\'s own series');-- assert.strictEqual(fallbackSeries.total, 5, 'the fallback is Live\'s own series, not zeros');++ assert.strictEqual(chatReads.homeSeriesPeek('messages', 7), null, 'a cold + down peek answers null');readState.down = false;chatReads._reset();@@@ -866,9 +898,8 @@ const waitFor = async (pred, what, ms = 3000) => {assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');chatReads._reset();d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);-- assert.strictEqual(d2.isFirstChatInChannel(`user:${viewer}`, streamer), true, 'Live\'s frozen table would say first');readState.down = true;-- assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first, never Live\'s frozen table');++ assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first (Live keeps no first-chat table)');readState.down = false;chatReads._reset();@@@ -1,11 +1,11 @@'use strict';/**-- * Live's chat history reads now come from OpenVibe.Chat (roadmap T3 J4b: Read Live's chat stats,++ * Live's chat history reads come from OpenVibe.Chat (roadmap T3 J4b: Read Live's chat stats,* queues and history from Chat). Live's own history store — the page/delta reads over its copy of-- * chat_messages — is gone with the read mirror, so nothing keeps a second answer to "what happened-- * in this room?". These checks guard that the dead module stays deleted, and that the replacement-- * (server/chat/chat-reads.js) answers history from Live's own tables while Live runs chat itself-- * (the rollback / dev path) with the same rules: deleted and auto-expired rows never come back.++ * chat_messages — went with the read mirror, and the table itself is dropped (op_003), so nothing++ * keeps a second answer to "what happened in this room?". These checks guard that the dead module++ * stays deleted, and that outside chat mode (dev, drills — Live runs no chat server either way) the++ * read seam answers empty/null rather than any local value.*/const assert = require('assert');const fs = require('fs');@@@ -16,45 +16,20 @@ const ROOT = path.join(__dirname, '..');assert.ok(!fs.existsSync(path.join(ROOT, 'server', 'chat', 'history-store.js')), 'history-store.js is deleted — history is Chat\'s now');process.env.DB_PATH = path.join(os.tmpdir(), `ov-chat-history-${Date.now()}.db`);--delete process.env.CHAT_AUTHORITY; // Live runs chat itself: chat-reads answers from Live's tables++delete process.env.CHAT_AUTHORITY; // not chat mode: the seam answers its empty local answersconst db = require('../server/db/database');db.initDb();const reads = require('../server/chat/chat-reads');--const u = db.run(`INSERT INTO users (username, password_hash, email) VALUES ('alice', 'x', '[email protected]')`).lastInsertRowid;--const bob = db.run(`INSERT INTO users (username, password_hash, email) VALUES ('bob', 'x', '[email protected]')`).lastInsertRowid;----const ids = [];--for (let i = 1; i <= 12; i++) {-- const r = db.saveChatMessage({ user_id: u, username: 'alice', message: `m${i}`, stream_id: null, is_global: 1, message_type: 'chat' });-- ids.push(Number(r.lastInsertRowid));--}--// One deleted and one auto-expired row must never come back through either read.--db.run(`UPDATE chat_messages SET is_deleted = 1 WHERE id = ?`, [ids[5]]);--db.run(`UPDATE chat_messages SET auto_delete_at = datetime('now', '-1 minute') WHERE id = ?`, [ids[6]]);--// A channel room row (channel_user_id set) belongs to its channel's history.--const chanId = Number(db.saveChatMessage({ user_id: bob, username: 'bob', message: 'channel only', stream_id: null, channel_user_id: bob, message_type: 'chat' }).lastInsertRowid);--(async () => {-- // A user's site-wide history (deleted and auto-expired rows never come back). The rows share a-- // timestamp here, so compare as a set.-- const hist = await reads.userHistory(u, { limit: 50 });-- assert.deepStrictEqual(hist.messages.map((m) => m.message).sort(), ['m1', 'm10', 'm11', 'm12', 'm2', 'm3', 'm4', 'm5', 'm8', 'm9'], 'user history, no deleted/expired rows');-- assert.ok(hist.total >= hist.messages.length, 'a total is reported for paging');---- // A channel's recent lines, oldest→newest — what an AI viewer's prompt sees.-- const ch = await reads.channelMessages(bob, 10);-- assert.deepEqual(ch.map((m) => m.message), ['channel only'], 'only the channel room, oldest → newest');-- assert.strictEqual(ch[ch.length - 1].id, chanId);---- // A chat search by user id (the admin console), same rows.-- const search = await reads.searchMessages({ userId: u, limit: 50 });-- assert.strictEqual(search.messages.length, hist.messages.length, 'search by user id matches the history');---- // One message by id (the moderation delete path).-- const one = await reads.messageById(ids[0]);-- assert.strictEqual(one.message, 'm1');-- assert.strictEqual(await reads.messageById(10 ** 9), null, 'an unknown id is null');---- console.log('chat-history-store: ok — Live\'s store is deleted; chat-reads answers history in local mode');++ assert.deepStrictEqual(await reads.userHistory(1, { limit: 50 }), { messages: [], total: 0 }, 'no local user history');++ assert.deepStrictEqual(await reads.channelMessages(1, 10), [], 'no local channel page');++ assert.deepStrictEqual(await reads.searchMessages({ userId: 1, limit: 50 }), { messages: [], total: 0 }, 'no local search');++ assert.strictEqual(await reads.messageById(1), null, 'no local message by id');++ // The dropped tables are not recreated by a boot.++ for (const t of ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',++ 'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions']) {++ assert.ok(!db.getDb().prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t), `${t} must not exist`);++ }++ console.log('chat-history-store: ok — Live\'s store is deleted; the read seam answers empty outside chat mode');})().catch((e) => { console.error(e); process.exit(1); });@@@ -45,13 +45,16 @@ const network = http.createServer((req, res) => {});});--// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its first-chat--// and hidden-relay-user reads answer from Live's own tables (emulating Chat's copy).++// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its++// first-chat, hidden-relay-user and message reads answer from this stub's own state (Live keeps no++// copy of any of those tables — OpenVibe.Chat owns them).const calls = [];const bridgeCalls = [];const fail = {};let nextId = 5000;let firstChatReads = 0;++// Chat's own copy: a chatter's first line is recorded when the message lands, hides are rows.++const chatState = { firstChats: new Set(), relayHidden: [], messages: new Map() };const chat = http.createServer((req, res) => {let raw = '';req.on('data', (c) => { raw += c; });@@@ -65,29 +68,29 @@ const chat = http.createServer((req, res) => {if (String(req.url).startsWith('/internal/chat/first-chat')) {firstChatReads++;const q = new URL(req.url, 'http://x').searchParams;-- const d3 = require('../server/db/database');-- const row = d3.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);-- return res.end(JSON.stringify({ ok: true, first: !row }));++ return res.end(JSON.stringify({ ok: true, first: !chatState.firstChats.has(String(q.get('identity'))) }));}if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {const q = new URL(req.url, 'http://x').searchParams;-- const rows = require('../server/db/database').all('SELECT * FROM hidden_relay_users WHERE channel_id = ? OR channel_id IS NULL LIMIT ?', [Number(q.get('channel_id')), Number(q.get('limit') || 100)]);++ const channelId = Number(q.get('channel_id'));++ const rows = chatState.relayHidden.filter((r) => r.channel_id === channelId || r.channel_id == null);return res.end(JSON.stringify({ ok: true, relay_users: rows }));}const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);if (!m) { res.statusCode = 404; return res.end('{}'); }const family = m[1];if (family === 'presence') return res.end(JSON.stringify({ total: 4, streams: { 1: 2 }, slow_mode: {}, users: [{ user_id: 99, ip: '198.51.100.9', stream_id: 1 }], anons: [] }));-- // Chat's message READ (Chat holds the rows now; Live reads them through it): answer the-- // row Live's own copy carries, so the moderation path's permission check sees it.++ // Chat's message READ (Chat holds the rows; Live reads them through it): answer from the++ // stub's own copy, so the moderation path's permission check sees the row.if (family === 'messages' && req.method === 'GET') {-- const d3 = require('../server/db/database');const id = Number(new URL(req.url, 'http://x').searchParams.get('id'));-- const row = d3.getChatMessageById(id);++ const row = chatState.messages.get(id) || null;return res.end(JSON.stringify({ ok: true, messages: row ? [row] : [], max_id: row ? id : null }));}const body = JSON.parse(raw || '{}');calls.push({ family, auth, body });++ // Chat records a chatter's first line when the message lands.++ if (family === 'messages' && body.username) chatState.firstChats.add(`ext:${body.username}`);const f = fail[family];if (f && f.times > 0) { f.times--; res.statusCode = f.status; return res.end(JSON.stringify({ error: 'down' })); }if (family === 'messages') return res.end(JSON.stringify({ ok: true, id: ++nextId }));@@@ -166,7 +169,7 @@ function assertSigned(c, what) {assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");-- assert.ok(!d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live writes no first-chat copy of its own in chat mode');++ assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stream_first_chats'").get(), 'Live has no first-chat copy of its own');const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');@@@ -176,15 +179,15 @@ function assertSigned(c, what) {// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;const chatReads = require('../server/chat/chat-reads');-- d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);-- chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes the row directly++ chatState.relayHidden.push({ id: 1, channel_id: channel.id, platform: 'twitch', external_username: 'alice', action: 'hide' });++ chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes Chat's row directlyawait waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");const beforeHidden = relayLines();relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});await sleep(80);assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');assert.strictEqual(relayLines('should not relay'), 0);-- d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();++ chatState.relayHidden.length = 0;chatReads.invalidate('hru:'); // as the unhide route doesrelay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');@@@ -213,12 +216,12 @@ function assertSigned(c, what) {const r = await fetch(`http://127.0.0.1:${port}/api/mod${p}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` }, body: JSON.stringify(body) });return { status: r.status, body: await r.json().catch(() => null) };};-- d.prepare("INSERT INTO chat_messages (id, stream_id, channel_user_id, user_id, username, message) VALUES (900010, ?, ?, ?, 'VIEWER', 'bad')").run(streamId, streamer, viewer);++ chatState.messages.set(900010, { id: 900010, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'bad' });assert.strictEqual((await mod('/delete-message', { message_id: 900010 })).status, 200);const del = find('moderation', (b) => b.action === 'delete-message');assertSigned(del, 'message delete');assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);-- assert.strictEqual(db.getChatMessageById(900010).is_deleted, 0, "Live's own copy is untouched: Chat owns the delete (the read mirror is retired)");++ assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_messages'").get(), 'Live has no chat table to update: Chat owns the delete');assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });assert.ok(ban.status < 300, `ban answered ${ban.status}`);nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++++// The twelve OpenVibe.Chat-owned chat tables (roadmap T3, final step). Live's readers and writers++// are gone (#28–#32 and the release that carries this file), it no longer creates them, and++// op_003_drop_chat_tables (scripts/chat-tables-drop.js) drops them. The drop is an operator++// contract step, not a boot migration: the release before this one still runs SQL over them++// (test/n-1.test.js), so it may only run once that release is out of rollback range (ADR-028).++// This guard: nothing under server/ names any of the twelve (the drop migration's own statements++// are the one exception), a freshly migrated database has none, and the operator migration drops++// legacy copies and is idempotent.++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++++const ROOT = path.join(__dirname, '..');++++const TABLES = [++ 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',++ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',++ 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',++];++const T = TABLES.join('|');++++const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');++const WRITE_SQL = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');++const SCHEMA_SQL = new RegExp(`\\b(CREATE\\s+(?:TABLE|INDEX|TRIGGER)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?|ALTER\\s+TABLE\\s+|PRAGMA\\s+(?:table_info|table_xinfo|index_list|foreign_key_list)\\s*\\(\\s*[\`"']?)(${T})\\b`, 'i');++// The operator drop (server/db/migrations.js) is the one place allowed to name them.++const DROP_STMT = new RegExp(`^\\s*DROP\\s+(?:TABLE|INDEX|TRIGGER)\\s+IF\\s+EXISTS\\s+(?:${T})\\s*;\\s*$`, 'i');++++function walk(dir, out = []) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); }++ else if (e.name.endsWith('.js') || e.name.endsWith('.sql')) out.push(p);++ }++ return out;++}++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);++++/** Every read matching one of `regexes` in a file's text → [{ line, text }]. */++function findReads(src, regexes) {++ const lines = src.split('\n');++ const hits = [];++ for (const re of regexes) {++ re.lastIndex = 0;++ let m;++ while ((m = re.exec(src))) {++ const startLine = src.slice(0, m.index).split('\n').length;++ const endLine = src.slice(0, m.index + m[0].length - 1).split('\n').length;++ if (isCommentLine(lines[startLine - 1]) || isCommentLine(lines[endLine - 1])) continue;++ hits.push({ line: endLine, text: lines[endLine - 1].trim() });++ }++ }++ return hits;++}++++// 1. No server/ code names any of the twelve (the drop migration's DROP lines excepted).++const offenders = [];++for (const file of walk(path.join(ROOT, 'server'))) {++ const rel = path.relative(ROOT, file);++ const src = fs.readFileSync(file, 'utf8');++ const lines = src.split('\n');++ if (rel !== path.join('server', 'db', 'migrations.js')) {++ for (const hit of findReads(src, [READ_SQL])) offenders.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);++ }++ lines.forEach((line, i) => {++ if (isCommentLine(line)) return;++ if (rel === path.join('server', 'db', 'migrations.js') && DROP_STMT.test(line)) return;++ if (WRITE_SQL.test(line) || SCHEMA_SQL.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);++ });++}++assert.deepStrictEqual(offenders, [], `server/ still names OpenVibe.Chat's chat tables (read and write them through Chat's API; the operator drop is the only exception):\n${offenders.join('\n')}`);++++// 2. A database created by the schema has none of them.++const fresh = new Database(':memory:');++fresh.exec(fs.readFileSync(path.join(ROOT, 'server', 'db', 'schema.sql'), 'utf8'));++for (const t of TABLES) {++ assert.ok(!fresh.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t), `fresh schema must not create ${t}`);++}++fresh.close();++++// 3. A boot neither creates nor drops them (the drop is the operator step), and op_003 drops them.++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-tables-dropped-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++const legacy = new Database(process.env.DB_PATH);++for (const t of TABLES) legacy.exec(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY)`);++legacy.close();++++const db = require('../server/db/database');++db.initDb();++const d = db.getDb();++const exists = (t) => !!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);++for (const t of TABLES) assert.ok(exists(t), `${t} must survive a boot: dropping it is the operator step, not a boot migration`);++assert.ok(!d.prepare("SELECT 1 FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'").get(), 'op_003 is not a boot migration');++++const migrations = require('../server/db/migrations');++assert.ok(!migrations.MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is not in the boot list');++assert.ok(migrations.OPERATOR_MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is an operator migration');++assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'applied', 'the operator drop applies');++for (const t of TABLES) assert.ok(!exists(t), `${t} must be gone after op_003`);++assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'already', 'the operator drop is idempotent');++db.close();++fs.rmSync(tmp, { recursive: true, force: true });++++console.log(`chat tables dropped: no server/ code names the ${TABLES.length}; fresh schema none; op_003 drops legacy copies (idempotent)`);++process.exit(0);ddeleted file mode 100644@@@ -1,41 +0,0 @@--'use strict';--// Deploy notices fold into one rolling row, never repeat a commit, and stay silent without new code.--const assert = require('assert');--const dn = require('../server/chat/deploy-notice');----const rows = []; const settings = {};--const db = {-- getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },-- get: () => rows.filter(r => !r.is_deleted).slice(-1)[0] || undefined, // newest row of ANY room-- run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },-- saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },--};--const c = (n) => ({ hash: String(n).repeat(40).slice(0, 40).replace(/[^0-9a-f]/g, 'a'), short: 'c' + n, subject: 'change ' + n, date: new Date().toISOString() });----const first = dn.persist(db, [c(1)]);--assert.equal(rows.length, 1); assert.equal(first.meta.deploys, 1);--const second = dn.persist(db, [c(2), c(1)]); // overlap with what is already there--assert.equal(rows.length, 1, 'a second deploy updates the same row');--assert.equal(second.id, first.id);--assert.deepEqual(second.meta.commits.map(x => x.short), ['c2', 'c1'], 'each commit once, newest first');--assert.equal(second.meta.deploys, 2);--assert.ok(/^🚀 2 updates shipped/.test(rows[0].message));----rows.push({ id: 2, is_global: 1, is_deleted: 0, message_type: 'chat', message: 'hello', metadata: null }); // someone spoke--const third = dn.persist(db, [c(3)]);--assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');--assert.equal(third.meta.deploys, 1);----// A message in a STREAM room (not global, but shown in the global feed) also ends the card: folding--// past it would give the card a time range running past the message shown under it.--rows.push({ id: rows.length + 1, is_global: 0, is_deleted: 0, message_type: 'chat', message: 'hello brother', metadata: null });--const fourth = dn.persist(db, [c(4)]);--assert.equal(rows.length, 5, 'a stream-room message starts a new card too');--assert.equal(fourth.meta.deploys, 1);----const old = JSON.parse(rows[4].metadata); old.first_at = new Date(Date.now() - 4 * 3600 * 1000).toISOString(); rows[4].metadata = JSON.stringify(old);--dn.persist(db, [c(5)]);--assert.equal(rows.length, 6, 'a notice older than the 3-hour fold window is not reopened');----assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');--console.log('deploy notice: all checks passed');ddeleted file mode 100644@@@ -1,54 +0,0 @@--'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');--const fs = require('fs');----const tempDbPath = path.join(os.tmpdir(), `openvibelive-dm-leak-${Date.now()}.db`);--process.env.DB_PATH = tempDbPath;----const db = require('../server/db/database');--const dm = require('../server/chat/dm');----async function run() {-- try {-- db.initDb();-- dm.ensureTables();---- const userA = db.createUser({ username: 'accountA', email: null, password_hash: '!test', display_name: 'Account A', stream_key: 'streamA' }).lastInsertRowid;-- const userB = db.createUser({ username: 'accountB', email: null, password_hash: '!test', display_name: 'Account B', stream_key: 'streamB' }).lastInsertRowid;-- const userC = db.createUser({ username: 'accountC', email: null, password_hash: '!test', display_name: 'Account C', stream_key: 'streamC' }).lastInsertRowid;-- const userD = db.createUser({ username: 'accountD', email: null, password_hash: '!test', display_name: 'Account D', stream_key: 'streamD' }).lastInsertRowid;---- const convId = dm.createConversation(userA, [userA, userB, userC], 'Test Group');-- const participants = dm.getParticipants(convId).map(u => u.id).sort((a, b) => a - b);-- assert.deepStrictEqual(participants, [userA, userB, userC]);-- assert(dm.isParticipant(convId, userA), 'userA should be participant');-- assert(dm.isParticipant(convId, userB), 'userB should be participant');-- assert(dm.isParticipant(convId, userC), 'userC should be participant');-- assert(!dm.isParticipant(convId, userD), 'userD should not be a participant');---- // 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);---- console.log('✅ DM helpers (participants, blocks) regression test passed');-- } finally {-- try { db.close(); } catch {};-- try { fs.unlinkSync(tempDbPath); } catch {};-- }--}----run().catch((err) => {-- console.error('DM helpers test failed:', err);-- process.exit(1);--});@@@ -133,13 +133,12 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };db.run("INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'Restored stream', 'webrtc', 1, 3, datetime('now'), datetime('now'))", [star]);db.run("INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at) VALUES (?, 'Old stream', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))", [star]);db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, star]);-- db.run("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, ?, 'drillfan', 'hello from the backup')", [fan]);db.initDb(); // production booted since these rows were written (its backfills already ran on them)db.close();`], { cwd: REPO, encoding: 'utf8', env: { ...process.env, DB_PATH, DATA_DIR, NODE_ENV: 'test' } });assert.strictEqual(seed.status, 0, `seeding the copy failed:\n${seed.stdout}\n${seed.stderr}`);const Database = require('better-sqlite3');-- const COUNTED = ['users', 'channels', 'managed_streams', 'streams', 'follows', 'chat_messages'];++ const COUNTED = ['users', 'channels', 'managed_streams', 'streams', 'follows'];const counts = () => { const d = new Database(DB_PATH, { readonly: true }); try { return Object.fromEntries(COUNTED.map((t) => [t, d.prepare(`SELECT count(*) AS n FROM ${t}`).get().n])); } finally { d.close(); } };const countsBefore = counts();@@@ -248,7 +247,7 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };}const stats = await request('GET', '/api/home/stats-live');assert.strictEqual(stats.json.liveNow === undefined ? 1 : stats.json.liveNow, 1);-- assert.strictEqual(stats.json.chatMessages, 1);++ assert.strictEqual(stats.json.chatMessages, 0, 'no local chat count: OpenVibe.Chat owns the chat table');});await check('a route that asks Media answers as if Media were down: the call is refused, not sent', async () => {const r = await request('GET', '/api/streams/channel/drillstar');@@@ -1,12 +1,8 @@{"_about": "Destructive schema statements in server/ that are known and accounted for (roadmap WS-P task 12, expand/migrate/contract). A new DROP TABLE, DROP COLUMN, RENAME COLUMN or RENAME TO of a real table fails test/destructive-migrations.test.js until it is listed here with its contract step: the dated plan entry (section 10) that runs it after a release where nothing reads the old shape, with a backup. Temporary tables (_x, x_new, x_old, x_tmp) are not counted.","allowed": [-- { "file": "server/db/database.js", "statement": "DROP TABLE CHAT_MESSAGES", "count": 2, "contract": "pre-ADR table rebuild (copy every column into chat_messages_new, drop, rename); guarded, one-time" },-- { "file": "server/db/database.js", "statement": "RENAME TO CHAT_MESSAGES", "count": 2, "contract": "the same rebuild" },{ "file": "server/db/database.js", "statement": "DROP TABLE ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },{ "file": "server/db/database.js", "statement": "RENAME TO ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "the same rebuild" },-- { "file": "server/db/database.js", "statement": "DROP TABLE CHANNEL_SOUNDS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },-- { "file": "server/db/database.js", "statement": "RENAME TO CHANNEL_SOUNDS", "count": 1, "contract": "the same rebuild" },{ "file": "server/db/database.js", "statement": "DROP TABLE STREAM_CONTROLS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },{ "file": "server/db/database.js", "statement": "RENAME TO STREAM_CONTROLS", "count": 1, "contract": "the same rebuild" },{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_MODERATORS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },@@@ -17,6 +13,18 @@{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_STAGED_OUTBOX", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the staged-table relay was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS EMOTES", "count": 1, "contract": "roadmap T3 N+3 (006_drop_emotes): Chat owns emotes; N-1 fb3957f does not read its Live copy; backups kept operator-side" },-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_BRIDGE_OUTBOX", "count": 1, "contract": "roadmap T3 J2 (op_002_drop_chat_bridge_outbox, operator-run via scripts/chat-bridge-outbox-drop.js): Live's chat bridge was deleted in this release; N-1 still writes the table, so the drop waits until N-1 is out of rollback range, delivers unacknowledged writes to Chat first and refuses while any remain; backups kept operator-side" }++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_BRIDGE_OUTBOX", "count": 1, "contract": "roadmap T3 J2 (op_002_drop_chat_bridge_outbox, operator-run via scripts/chat-bridge-outbox-drop.js): Live's chat bridge was deleted in this release; N-1 still writes the table, so the drop waits until N-1 is out of rollback range, delivers unacknowledged writes to Chat first and refuses while any remain; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_PARTICIPANTS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_CONVERSATIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_BLOCKS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS TTS_VOICE_OVERRIDES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_SOUNDS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS HIDDEN_RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS PENDING_IP_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS STREAM_FIRST_CHATS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS MODERATION_ACTIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" }]}ddeleted file mode 100644@@@ -1,91 +0,0 @@--'use strict';--// Live's staff actions reach OpenVibe.Network's moderation audit log (ADR-022, WS-D task 1, Contracts--// 0.46.0): logModerationAction writes live.moderation.action to the outbox in the same transaction as the--// row (valid against the contract, actor = the staff member's subject); tidying one's own messages,--// configuring one's own channel and acting on oneself stay local; with the outbox off the row is still--// written.--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modevents-'));--process.env.DB_PATH = path.join(tmp, 'live.db');--const quiet = console.log;--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };--console.warn = () => {};----const published = [];--const stub = http.createServer((req, res) => {-- let body = '';-- req.on('data', (c) => { body += c; });-- req.on('end', () => {-- res.setHeader('Content-Type', 'application/json');-- if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));-- if (req.url === '/api/v1/events' && req.method === 'POST') {-- const parsed = JSON.parse(body);-- const list = parsed.events || [parsed];-- const { validate } = require('openvibe-contracts');-- const bad = list.find((e) => !validate('events.event-envelope@1', e).valid);-- if (bad) { res.statusCode = 422; return res.end(JSON.stringify({ code: 'events.invalid_envelope' })); }-- const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });-- return res.end(JSON.stringify(parsed.events ? { results } : results[0]));-- }-- res.statusCode = 404; res.end('{}');-- });--});----(async () => {-- await new Promise((r) => stub.listen(0, '127.0.0.1', r));-- const base = `http://127.0.0.1:${stub.address().port}`;-- process.env.OV_NETWORK_INTERNAL_URL = base;-- const { validate } = require('openvibe-contracts');-- const db = require('../server/db/database');-- db.initDb();-- const d = db.getDb();-- d.prepare("INSERT INTO users (id, username, display_name, password_hash, role) VALUES (1, 'boss', 'Boss', 'x', 'admin'), (2, 'spammer', 'Spammer', 'x', 'user')").run();-- d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '10', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ'), (2, 'network', '20', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR')").run();-- const rows = () => d.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get().n;---- // Outbox off: the row is written, nothing is queued.-- db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: { reason: 'spam' } });-- assert.strictEqual(rows(), 1);---- const streamEvents = require('../server/events/stream-events');-- const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });-- const r = db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'global_ban', details: { reason: 'spam', ban_type: 'permanent' } });-- db.logModerationAction({ scope_type: 'stream', scope_id: '77', actor_user_id: 1, target_user_id: 2, action_type: 'stream_force_end', details: {} });-- db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'self_message_delete_all', details: {} });-- db.logModerationAction({ scope_type: 'channel', scope_id: 5, actor_user_id: 2, target_user_id: null, action_type: 'channel_settings_update', details: {} });-- db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'message_delete', details: {} });-- assert.strictEqual(rows(), 6, 'every action is still logged locally');-- await outbox.flush();-- const evs = published.filter((e) => e.event_type === 'live.moderation.action');-- assert.deepStrictEqual(evs.map((e) => e.payload.action_type), ['global_ban', 'stream_force_end'], 'only moderation of someone else');-- for (const e of evs) { const v = validate('live.moderation.action@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); }-- const [ban, end] = evs;-- assert.deepStrictEqual(ban.actor, { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });-- assert.deepStrictEqual(ban.subject, { type: 'moderation_action', id: String(r.lastInsertRowid) });-- assert.strictEqual(ban.payload.target_subject, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');-- assert.deepStrictEqual(ban.payload.details, { reason: 'spam', ban_type: 'permanent' });-- assert.strictEqual(ban.priority, 'important'); assert.strictEqual(ban.visibility, 'internal');-- assert.strictEqual(end.payload.scope_id, 77);---- // The backfill keeps each action's original time.-- d.transaction(() => db.announceModerationAction(1, { scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: {} }, { at: '2026-04-13T20:56:38.000Z' }))();-- await outbox.flush();-- assert.strictEqual(published.at(-1).timestamp, '2026-04-13T20:56:38.000Z');---- // A failed insert queues nothing (the event is in its transaction).-- const before = published.length;-- assert.throws(() => db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: null }));-- await outbox.flush();-- assert.strictEqual(published.length, before);---- outbox.stop && outbox.stop();-- stub.close();-- fs.rmSync(tmp, { recursive: true, force: true });-- quiet('moderation events: all checks passed');-- process.exit(0);--})().catch((err) => { console.error(err); process.exit(1); });@@@ -47,7 +47,6 @@ const SEED = `d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'N-1 live stream', 'tech', 'webrtc', 1, 2, datetime('now', '-10 minutes'), datetime('now'))").run(star);d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at) VALUES (?, 'N-1 past stream', 'irl', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))").run(star);d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(fan, star);-- d.prepare("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, ?, 'n1fan', 'hello from N-1')").run(fan);db.close();`;@@@ -14,9 +14,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repdb.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'Late night tinkering', 'irl', 'webrtc', ts(70), ts(10), 3600, 9]);const sid = db.get('SELECT id FROM streams ORDER BY id DESC LIMIT 1').id;for (let i = 0; i < 12; i++) db.run('INSERT INTO viewer_snapshots (stream_id, viewer_count, chat_messages_5m, recorded_at) VALUES (?,?,?,?)', [sid, 2 + Math.round(7 * Math.sin(i / 11 * Math.PI)), i === 6 ? 14 : 3, ts(70 - i * 5)]);--for (let i = 0; i < 9; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan, 'fan1', `msg ${i}`, ts(60 - i)]);--for (let i = 0; i < 4; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan2, 'fan2', `yo ${i}`, ts(50 - i)]);--db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, host, 'hostess', 'hi chat', ts(55)]);++// Chat owns chat history now (Live keeps no copy): the 9 fan1 + 4 fan2 lines and the host's one++// are what Chat's stats/top read answers, stubbed here. The host is excluded from the top list.++const chatReads = require('../server/chat/chat-reads');++chatReads.streamStats = async () => ({ messages: 14, chatters: 3, sounds: 0 });++chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];db.run("INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)", [fan2, host, ts(30)]);db.run("INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)", [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);db.run("INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)", [fan, host, sid, 5, 'donation', 'completed']);@@@ -1,9 +1,9 @@'use strict';// Deploys as events (roadmap Wave 3 exit): the deploy notice queues live.release.deployed in the--// same transaction that records the commits as announced (and stores the chat row), publishes it--// through Live's outbox, keeps the chat notice working (local row, or the Chat bridge), says--// nothing on a restart without new code, and rolls the notice back if the event cannot be queued.++// same transaction that records the commits as announced, publishes it through Live's outbox (Chat++// folds the rolling card from that event alone — Live keeps no chat table), says nothing on a++// restart without new code, and rolls the announcement back if the event cannot be queued.const assert = require('assert');const fs = require('fs');@@@ -45,7 +45,6 @@ const stub = http.createServer((req, res) => {const streamEvents = require('../server/events/stream-events');const quiet = { log() {}, warn() {} };const outboxRows = () => raw.prepare('SELECT event_id, envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === releaseEvents.EVENT_TYPE);-- const deployRows = () => raw.prepare("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%'").all();// The envelope is a valid event-envelope@1.const head = (await new Promise(r => require('child_process').execFile('git', ['rev-parse', 'HEAD'], { cwd: path.join(__dirname, '..') }, (e, o) => r(String(o).trim()))));@@@ -57,12 +56,12 @@ const stub = http.createServer((req, res) => {assert.strictEqual(sample.payload.release, head.slice(0, 7));assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);-- // Events off: the notice works exactly as before and nothing is queued.++ // Events off: Chat has no other way to hear about a deploy (its ingress has no deploy endpoint),++ // so nothing is announced or recorded — the next boot tries again.let r = await dn.announce({ db, log: quiet });-- assert.ok(r.announced >= 1);++ assert.strictEqual(r.announced, 0);assert.strictEqual(r.event_id, null);-- assert.strictEqual(db.getSetting(dn.SETTING), head);-- assert.strictEqual(deployRows().length, 1);++ assert.ok(!db.getSetting(dn.SETTING), 'not recorded as announced');// Events on: a new deploy (the setting points elsewhere) queues and publishes the event.const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });@@@ -90,20 +89,17 @@ const stub = http.createServer((req, res) => {assert.strictEqual(r.announced, 0);assert.strictEqual(outboxRows().length, 1);-- // The event cannot be queued: nothing is recorded (no chat row, setting unchanged), so the next-- // boot announces again — never a notice without its event.++ // The event cannot be queued: nothing is recorded (setting unchanged), so the next boot++ // announces again — never an announcement without its event.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, log: quiet });raw.exec('DROP TRIGGER temp.outbox_boom');assert.strictEqual(r.announced, 0);assert.strictEqual(db.getSetting(dn.SETTING), '');-- assert.strictEqual(deployRows().map(x => x.metadata).join('|'), chatBefore, 'the chat row rolled back too');assert.strictEqual(outboxRows().length, 1);-- // CHAT_AUTHORITY=chat with Events on: Chat learns the deploy from the event alone (C-84).-- process.env.CHAT_AUTHORITY = 'chat';++ // With Events on: Chat learns the deploy from the event alone (C-84).r = await dn.announce({ db, log: quiet });assert.ok(r.announced >= 1);assert.strictEqual(db.getSetting(dn.SETTING), head);@@@ -118,7 +114,6 @@ const stub = http.createServer((req, res) => {db.setSetting(dn.SETTING, '');r = await dn.announce({ db, log: quiet });delete process.env.EVENTS_PUBLISH;-- delete process.env.CHAT_AUTHORITY;assert.strictEqual(r.announced, 0);assert.strictEqual(db.getSetting(dn.SETTING), '', 'not recorded as announced');assert.strictEqual(outboxRows().length, 2, 'nothing queued while Events is off');@@@ -66,7 +66,7 @@ try {// Tables the newer release added, filled the way it fills them.try { require('./server/events/search-documents').ensureSchema(); d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id); } catch (e) {}try { require('./server/events/search-media-documents').ensureSchema(); d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run(); } catch (e) {}-- db.logModerationAction({ scope_type: 'site', actor_user_id: u.id, target_user_id: null, action_type: 'probe', details: {} });++ db.recordEasterEggSolve('2026-01-01', 'rollback-probe', u.id);const tables = d.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables }) + '\\n');`);@@@ -54,14 +54,20 @@ addUser(5, 'dests');const e1 = slot(5, 'x'), e2 = slot(5, 'y');const r5 = rsRow(5, null, { robot_id: '11' });// Its streams on slot y carried mirrored RobotStreamer chat: the account-level row was serving y.++// The script reads mirrored chat as evidence only when a legacy database still carries the table++// (OpenVibe.Chat owns chat_messages and Live no longer creates it), so the fixture recreates it.++raw.exec(`CREATE TABLE IF NOT EXISTS chat_messages (++ id INTEGER PRIMARY KEY AUTOINCREMENT, stream_id INTEGER, user_id INTEGER, username TEXT,++ message TEXT, source_platform TEXT, is_deleted INTEGER DEFAULT 0, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`);++const chat = (streamId, username, message, platform = null) => raw.prepare('INSERT INTO chat_messages (stream_id, username, message, source_platform) VALUES (?, ?, ?, ?)').run(streamId, username, message, platform);const e2stream = Number(db.createStream({ user_id: 5, managed_stream_id: e2, title: 'y', protocol: 'webrtc' }).lastInsertRowid);db.endStream(e2stream);--db.saveChatMessage({ stream_id: e2stream, username: '[RS] fan', message: 'hi', source_platform: 'rs' });--db.saveChatMessage({ stream_id: e2stream, username: '[RS] fan', message: 'again', source_platform: 'rs' });++chat(e2stream, '[RS] fan', 'hi', 'rs');++chat(e2stream, '[RS] fan', 'again', 'rs');// User 3's slot "one" was live with native chat only: that is no RobotStreamer evidence.const s1 = Number(db.createStream({ user_id: 3, managed_stream_id: c1, title: 'c1', protocol: 'webrtc' }).lastInsertRowid);db.endStream(s1);--db.saveChatMessage({ stream_id: s1, username: 'viewer', message: 'native chat is no evidence' });++chat(s1, 'viewer', 'native chat is no evidence');const script = require('../scripts/rs-integrations-to-slots');const out = [];@@@ -88,7 +88,6 @@ const SEED = `ins('INSERT INTO api_tokens (user_id, token_hash, label, scopes) VALUES (?, ?, ?, ?)', [owner, K.apiTokenHash, 'Bot', '["chat","read"]']);const cam = Number(ins('INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, ?, ?, ?, ?)', [owner, live, 'Desk cam', 'http://camera.example.test', 'admin', K.cameraPasswordHash]).lastInsertRowid);db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, owner]);-- db.run("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (?, ?, 'keyfan', 'hello')", [live, fan]);// What Live sends out about the stream, built from these rows the way production builds it.(async () => {@@@ -38,10 +38,13 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issd.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);++ // Chat persists and shows the line (Live keeps no chat table): stub the ingress call and record++ // what Live sends, and keep stubbing TTS synthesis (Chat speaks tts lines; the delivery seam is++ // Live's only path).const delivery = require('../server/chat/chat-delivery');-- const broadcasts = [];-- delivery.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });-- delivery.broadcastGlobal = () => {};++ const sent = [];++ let nextId = 700;++ delivery.message = async (body) => { sent.push(body); return nextId++; };const tts = [];delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };@@@ -65,19 +68,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issconst a = await post(job);assert.strictEqual(a.status, 200);assert.ok(a.json.ref.chat_message_id > 0);-- assert.strictEqual(broadcasts.length, 1);-- assert.strictEqual(broadcasts[0].uid, 501);-- assert.strictEqual(broadcasts[0].ev.type, 'donation');-- assert.strictEqual(broadcasts[0].ev.amount, 250);-- const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();-- assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');-- assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');++ assert.strictEqual(sent.length, 1);++ assert.deepStrictEqual([sent[0].channel_user_id, sent[0].message_type, sent[0].mirror], [501, 'donation', true]);++ assert.strictEqual(sent[0].message, 'Viewer tipped 250 Vibes: gg');++ assert.strictEqual(sent[0].metadata.source, 'tips');++ assert.strictEqual(sent[0].metadata.amount, 250);// A retry of the same delivery changes nothing.const b = await post(job);assert.deepStrictEqual(b.json, a.json);-- assert.strictEqual(broadcasts.length, 1);-- assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);++ assert.strictEqual(sent.length, 1, 'the retry posts nothing to Chat');// A Live restart between deliveries: the answered key is in SQLite, not in the old process.delete require.cache[require.resolve('../server/tips/delivery-routes')];@@@ -91,17 +91,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issconst afterRestart = await post2(job, 'tint_1:chat_line');assert.strictEqual(afterRestart.status, 200);assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');-- assert.strictEqual(broadcasts.length, 1, 'and delivers nothing again');-- assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);++ assert.strictEqual(sent.length, 1, 'and delivers nothing again');// A retry while the first attempt is still running is told to come back; a claim left by a// crash (older than two minutes) is taken over.d.prepare("INSERT INTO tips_deliveries (idempotency_key, effect, state, claimed_at) VALUES ('tint_9:chat_line', 'chat_line', 'pending', ?)").run(Date.now());assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 409);-- assert.strictEqual(broadcasts.length, 1);++ assert.strictEqual(sent.length, 1);d.prepare("UPDATE tips_deliveries SET claimed_at = ? WHERE idempotency_key = 'tint_9:chat_line'").run(Date.now() - 5 * 60 * 1000);assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 200);-- assert.strictEqual(broadcasts.length, 2);++ assert.strictEqual(sent.length, 2);// A refused delivery gives its key back, so Tips' retry runs it.const gone = { ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000X' } };@@@ -125,10 +124,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss// A hidden amount (Tips privacy.hide_amount sends null) stays hidden: no "0 Vibes" anywhere.const h = await post({ ...job, text: undefined, interaction: { ...job.interaction, id: 'tint_h', amount: null, message: 'hi' } }, { key: 'tint_h:chat_line' });assert.strictEqual(h.status, 200);-- assert.strictEqual(broadcasts[broadcasts.length - 1].ev.amount, null);-- const hs = d.prepare('SELECT * FROM chat_messages WHERE id = ?').get(h.json.ref.chat_message_id);-- assert.strictEqual(hs.message, 'Viewer sent a tip: hi');-- assert.strictEqual(JSON.parse(hs.metadata).amount, null);++ assert.strictEqual(sent[sent.length - 1].message, 'Viewer sent a tip: hi');++ assert.strictEqual(sent[sent.length - 1].metadata.amount, null);// Unknown creator: permanent refusal.const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
-**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`; Live reads them only through `server/chat/moderation-client.js` (Chat's internal read API, capability `chat.moderation.read`, cached 30 s) and the chat-AI summaries through `server/chat/insight-client.js` (Chat's public `/api/chat/ai/*`, cached 60 s), and keeps no copy of them (`test/chat-staged-tables.test.js` guards that nothing under `server/` reads or writes them; Live's `emotes` copy is unread since N+2 and dropped next); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
+**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`) once the release before is out of rollback range (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
## Commands
## Commands
@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:
### Data storage
### Data storage
-- `data/live.db` — primary SQLite database (users, streams, chat, channel state, AI state).
+- `data/live.db` — primary SQLite database (users, streams, channel state, AI state). Chat storage is OpenVibe.Chat's.
- `data/live-thumbs` — ephemeral live-stream thumbnails.
- `data/live-thumbs` — ephemeral live-stream thumbnails.
- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.
- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.
- `data/media/cache` — song-request (watch-party) downloads.
- `data/media/cache` — song-request (watch-party) downloads.
@@ -75,7 +75,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -75,7 +75,7 @@ The media subsystem lives in **OpenVibe.Media**:
## Does not own
## Does not own
- **Identity, sessions, follows, blocks, notifications, OpenCoins**: OpenVibe.Network. Live verifies its RS256 tokens offline and follows `network.user.*`, `network.follow.*` and deletion, export and merge events.
- **Identity, sessions, follows, blocks, notifications, OpenCoins**: OpenVibe.Network. Live verifies its RS256 tokens offline and follows `network.user.*`, `network.follow.*` and deletion, export and merge events.
-- **Chat messages and their tables**: OpenVibe.Chat (since 2026-09-23). Live bridges its WebSocket and moves the remaining staged tables one by one (`server/chat/chat-tables.js`).
+- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables Live still held are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`).
- **Recordings, clips, thumbnails, files**: OpenVibe.Media. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Recordings, clips, thumbnails, files**: OpenVibe.Media. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Pastes and comments**: OpenVibe.Community.
- **Pastes and comments**: OpenVibe.Community.
- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Only live-stream captions still use Live's whisper.cpp.
- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Only live-stream captions still use Live's whisper.cpp.
@@ -30,5 +30,5 @@
@@ -30,5 +30,5 @@
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
"channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default)"
"channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default)"
],
],
- "updated": "2026-10-04"
+ "updated": "2026-10-05"
}
}
@@ -225,9 +225,8 @@ transaction as the change it describes.
@@ -225,9 +225,8 @@ transaction as the change it describes.
| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
-The chat deploy notice itself is unchanged: stored in `chat_messages` (local chat) or handed to
-OpenVibe.Chat over the bridge (`CHAT_AUTHORITY=chat`). Once Chat consumes `live.release.deployed`, the
-bridge's `deployNotice` hop can be deleted.
+The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and
+stores the rolling card (C-84). Live keeps no chat table of its own.
Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied once
Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied once
through the SDK inbox (`idempotency_receipts`):
through the SDK inbox (`idempotency_receipts`):
@@ -50,8 +50,9 @@ path are deleted.
@@ -50,8 +50,9 @@ path are deleted.
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through
`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through
[server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,
[server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,
- recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value
- or Live's own table, never a 500.
+ recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value,
+ never a 500; outside chat mode (dev, drills) the same reads answer null/empty — Live keeps no copy of
+ Chat's tables (below).
- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`
- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`
writer and the capture triggers), and this change removed Live's receiver — the
writer and the capture triggers), and this change removed Live's receiver — the
`POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its
`POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its
@@ -59,20 +60,29 @@ path are deleted.
@@ -59,20 +60,29 @@ path are deleted.
read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,
read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,
the AI context's "first time chatting here" flag reads Chat's first-chat read, and the
the AI context's "first time chatting here" flag reads Chat's first-chat read, and the
RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through
RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through
- [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own table only
- while Chat is unreachable. Live's own chat tables stay until a later change drops them; the readers
- that still answer from them are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —
- `dm_*` (no `/api/dm`), the deploy notice, the admin chat log and the leftover dev/rollback fallbacks.
-- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,
- `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them
- only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal
- read API (`GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off under
- `LIVE_DRILL`). The dashboard, channel page and upload UI call Chat directly (`/api/chat/channels/:id/…`,
- `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request as an `alert`
- event `{ streamerId, streamId, kind }` on Chat's ingress ([server/monetization/alerts.js](../server/monetization/alerts.js)).
- Live's staged-table machinery (the write relay, dual read and handoff in `chat-tables*.js`), Live's
- chat-AI summary job and the old emote/channel-moderation routes were deleted in the N+1 release; Live's
- copies of the seven supporting tables were dropped in N+2, and the unread `emotes` copy in N+3 (ADR-016).
+ [server/chat/chat-reads.js](../server/chat/chat-reads.js); an unreachable Chat answers a cached value,
+ else null/empty, never a Live copy.
+- **Every chat table is Chat's** (roadmap T3). The staged set — `channel_moderators`,
+ `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events` —
+ is read only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) (Chat's
+ internal read API, `GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off
+ under `LIVE_DRILL`); the dashboard, channel page and upload UI call Chat directly
+ (`/api/chat/channels/:id/…`, `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on
+ Live's request as an `alert` event `{ streamerId, streamId, kind }` on Chat's ingress
+ ([server/monetization/alerts.js](../server/monetization/alerts.js)). Live's staged-table machinery, its
+ chat-AI summary job and the old emote/channel-moderation routes were deleted in N+1; the seven
+ supporting tables were dropped in N+2 and the unread `emotes` copy in N+3 (ADR-016).
+- **The twelve chat tables are dropped** (T3 final step, 2026-10-05): `chat_messages`,
+ `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`,
+ `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`
+ and `moderation_actions`. Chat imported them with ids kept at the cutover and has been their only
+ writer since; Live's readers and writers are gone (#28–#32 and this release), and it no longer creates
+ them. The drop itself is the operator migration `op_003_drop_chat_tables` via
+ [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js) (dry run, then `--apply`: backup, drop),
+ run once the release before is out of rollback range (ADR-028) — see
+ [docs/cutover-chat-tables-003.md](cutover-chat-tables-003.md). The deployment notice is Chat's too: Live
+ queues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js`
+ guards that nothing under `server/` names them.
## Features
## Features
@@ -193,8 +203,8 @@ each chat line is translated a beat after it is broadcast and delivered to the s
@@ -193,8 +203,8 @@ each chat line is translated a beat after it is broadcast and delivered to the s
Direction rules: any non-English message → English; an English message in a non-English
Direction rules: any non-English message → English; an English message in a non-English
channel → the channel's language (so a Japanese streamer reads his chat without anyone typing
channel → the channel's language (so a Japanese streamer reads his chat without anyone typing
Japanese). Emote-only / command / link-only lines are skipped. Translations are cached by
Japanese). Emote-only / command / link-only lines are skipped. Translations are cached by
-content hash in the `translations` table and persisted into `chat_messages.metadata.translation`,
-so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight;
+content hash in the `translations` table and persisted by Chat into the message's
+`metadata.translation`, so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight;
overflow lines simply stay untranslated).
overflow lines simply stay untranslated).
**Any line, your language.** Every chat message also has a hover 🌐 button that asks `POST /api/i18n/translate { text, to }` for the line in the viewer's browser language (rate-limited per IP: 15/min anonymous, 40/min signed in; cached like the automatic translations). This is the third direction — a Korean viewer reading a Japanese streamer, a Japanese viewer on an English stream — so nobody is left out, English users included.
**Any line, your language.** Every chat message also has a hover 🌐 button that asks `POST /api/i18n/translate { text, to }` for the line in the viewer's browser language (rate-limited per IP: 15/min anonymous, 40/min signed in; cached like the automatic translations). This is the third direction — a Korean viewer reading a Japanese streamer, a Japanese viewer on an English stream — so nobody is left out, English users included.
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+# Cutover: drop Live's copies of OpenVibe.Chat's chat tables (T3 final step, operator migration)
+
+This change removes Live's last references to the twelve chat tables OpenVibe.Chat owns —
+`chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`,
+`tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`,
+`stream_first_chats` and `moderation_actions` — stops creating them, and adds the operator migration
+**`op_003_drop_chat_tables`** ([server/db/migrations.js](../server/db/migrations.js)) plus the script
+that runs it, [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js).
+
+Chat imported these tables with ids kept at the 2026-09-23 cutover and has been their only writer
+since; Live's readers and writers are gone (PRs #28–#32 and the release that carries this migration).
+Unlike the boot migrations, `op_003` is **not** applied on boot: the release before this one still runs
+SQL over the tables (test/n-1.test.js), so they may only be dropped once that release is out of
+rollback range (ADR-028, `test/fixtures/destructive-migrations.json`). The tables are Live's own
+SQLite tables in `live.db`, not the shared PostgreSQL.
+
+## Order
+
+1. Confirm this release is the running one and has been through the rollback window:
+ `ov access run openvibe-ovh health live` answers `ready` and the release list shows it as current
+ (the previous release no longer in rollback range).
+2. From the release directory as the service user, dry run: `node scripts/chat-tables-drop.js` — it
+ prints which of the twelve tables exist and how many rows each holds; changes nothing.
+3. `node scripts/chat-tables-drop.js --apply` — writes
+ `<database dir>/backups/live-pre-chat-tables-drop-<time>.db` (mode 0600) and runs `op_003`, which
+ drops the twelve tables and their indexes. The ledger outcome is printed (`applied`, or `already`
+ on a repeat).
+
+## Backup
+
+- The script takes its own `live.db` backup before it drops anything and prints the path. Restoring
+ that file is the way back to a database that still holds the rows.
+- The host's wider snapshots (`ov access run openvibe-ovh db-backup`) are an alternative. The rows
+ themselves live in OpenVibe.Chat, which imported them with ids kept — the backup is the operator's
+ safety net, not their home.
+
+## How to check success
+
+- **Drop applied.** `--apply` prints `op_003_drop_chat_tables: applied`, and the read-only check on the
+ host prints nothing for the tables and `applied` for the ledger row:
+
+```sh
+sqlite3 -readonly <Live's data dir>/live.db \
+ "SELECT name FROM sqlite_master WHERE name IN ('chat_messages','dm_conversations','dm_participants','dm_messages','dm_blocks','tts_voice_overrides','channel_sounds','relay_users','hidden_relay_users','pending_ip_messages','stream_first_chats','moderation_actions')" # → nothing
+sqlite3 -readonly <Live's data dir>/live.db \
+ "SELECT id, mode FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'" # → op_003_…|applied
+```
+
+- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; a restart prints no
+ `no such table` for any of the twelve, home stats and the admin chat console still answer (from
+ Chat), and a chat line still reaches the watch page.
+- **Idempotent.** Rerunning `--apply` prints `already` and drops nothing further.
+
+## Rollback / restore
+
+Dropping the tables is destructive and a code rollback does not bring them back — the previous release
+runs fine without them (it reads them only on the paths production does not take). Restore only if the
+backup turns out to be needed: roll back the release (`ov access run openvibe-ovh rollback live`), stop
+Live, copy the script's backup (or the host `db-backup`) `live.db` over Live's database as the service
+user with the original owner and mode and no stale `-wal`/`-shm` beside it, and start Live. Restoring
+also discards every other Live write made since the backup, so prefer leaving the tables dropped.
+
+## Rehearsal
+
+[test/chat-tables-dropped.test.js](../test/chat-tables-dropped.test.js) checks the static guard
+(nothing under `server/` names any of the twelve, the drop migration's own statements excepted), that a
+fresh schema creates none of them, that a boot neither creates nor drops them, and that `op_003`
+drops legacy copies and is idempotent on a second run. `test/n-1.test.js` proves the previous release
+still prepares every statement it runs on a database this release migrated (which is why the drop must
+not run at boot).
deleted file mode 100644
deleted file mode 100644
@@ -1,52 +0,0 @@
@@ -1,52 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-/**
- * One-time backfill (WS-D task 1): Live's moderation actions logged before live.moderation.action
- * existed go to OpenVibe.Network's moderation audit log, each with its original time. Same rule as
- * logModerationAction (server/db/database.js): tidying one's own messages, configuring one's own channel
- * and acting on oneself are not sent. Idempotent: moderation_events_backfill remembers every row sent,
- * and --before (required) keeps it off rows the live code already announced.
- *
- * Run on the host AS LIVE, with Live's environment (EVENTS_URL, the OAuth client secret), e.g.:
- * node scripts/backfill-moderation-events.js --before '2026-09-25 20:31:27' [--dry-run]
- * It queues into Live's outbox and relays until the queue is empty. Nothing secret is printed.
- */
-const args = process.argv.slice(2);
-const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
-const before = flag('--before');
-const dryRun = args.includes('--dry-run');
-if (!before || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before)) {
- console.error("--before 'YYYY-MM-DD HH:MM:SS' (UTC, the deploy time of logModerationAction's event) is required");
- process.exit(2);
-}
-
-(async () => {
- const db = require('../server/db/database');
- db.initDb();
- const d = db.getDb();
- d.exec('CREATE TABLE IF NOT EXISTS moderation_events_backfill (action_id INTEGER PRIMARY KEY, sent_at DATETIME DEFAULT CURRENT_TIMESTAMP)');
- const rows = d.prepare(`SELECT m.* FROM moderation_actions m LEFT JOIN moderation_events_backfill b ON b.action_id = m.id
- WHERE b.action_id IS NULL AND m.created_at < ? ORDER BY m.id`).all(before);
- console.log(`${rows.length} action(s) logged before ${before} not yet backfilled`);
- if (dryRun) { process.exit(0); }
- const streamEvents = require('../server/events/stream-events');
- const outbox = streamEvents.init({ intervalMs: 500 });
- if (!outbox) { console.error('the outbox is off (EVENTS_URL or the client secret is missing)'); process.exit(1); }
- let sent = 0, skipped = 0;
- for (const r of rows) {
- let details = {};
- try { details = JSON.parse(r.details || '{}'); } catch { /* keep {} */ }
- const at = new Date(`${String(r.created_at).replace(' ', 'T')}Z`);
- d.transaction(() => {
- const ok = db.announceModerationAction(r.id, { ...r, details }, { at: Number.isNaN(at.getTime()) ? null : at.toISOString() });
- d.prepare('INSERT INTO moderation_events_backfill (action_id) VALUES (?)').run(r.id);
- if (ok) sent++; else skipped++;
- })();
- }
- console.log(`queued ${sent}, not moderation ${skipped}; relaying…`);
- for (let i = 0; i < 60 && streamEvents.status().pending > 0; i++) { await outbox.flush(); await new Promise((res) => setTimeout(res, 500)); }
- const st = streamEvents.status();
- console.log(`pending ${st.pending}, rejected ${st.rejected}${st.last_error ? ` (last error: ${st.last_error})` : ''}`);
- outbox.stop && outbox.stop();
- process.exit(st.rejected ? 1 : 0);
-})().catch((err) => { console.error(err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+/**
+ * The contract step for Live's copies of OpenVibe.Chat's chat tables (roadmap T3, final step): Live keeps no
+ * copy of chat_messages, the DM tables, or the moderation/relay/sound/TTS queues any more (the readers and
+ * writers went in #28–#32 and this release no longer creates them), but the release before this one still
+ * runs SQL over them (test/n-1.test.js), so the drop is operator-run. Run it once that release is out of
+ * rollback range.
+ *
+ * node scripts/chat-tables-drop.js [--db <live.db>]
+ * # dry run (the default): which of the twelve tables exist, with row counts; changes nothing
+ * node scripts/chat-tables-drop.js --apply [--backup <file.db>] [--db <live.db>]
+ * # an online backup first (default <database dir>/backups/live-pre-chat-tables-drop-<time>.db, mode
+ * # 0600), then server/db/migrations.js operator migration op_003_drop_chat_tables, which drops the
+ * # twelve tables and their indexes. Idempotent: a second run reports 'already'.
+ *
+ * The rows in them came from OpenVibe.Chat (its scripts/import-from-live.js imported them with ids kept at
+ * the 2026-09-23 cutover, and Chat has been their only writer since) and Live has read none of them since
+ * #32; the backup is the operator's safety net, not a second home for the data.
+ * Default database: $DB_PATH, else data/live.db under $DATA_DIR or the working directory (server/paths.js).
+ * In production run it from /opt/openvibe.live/current as the service user (data -> ../../shared/data).
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const ID = 'op_003_drop_chat_tables';
+const TABLES = [
+ 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
+ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',
+ 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
+];
+
+function parseArgs(argv) {
+ const opts = { mode: 'dry', db: null, backup: null, help: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ const next = () => {
+ const v = argv[++i];
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
+ return v;
+ };
+ if (a === '--apply') opts.mode = 'apply';
+ else if (a === '--dry-run') opts.mode = 'dry';
+ else if (a === '--db') opts.db = next();
+ else if (a === '--backup') opts.backup = next();
+ else if (a === '--help' || a === '-h') opts.help = true;
+ else throw new Error(`unknown option ${a}`);
+ }
+ return opts;
+}
+
+/** Which of the twelve exist, and how many rows each holds. Reads only. */
+function counts(db) {
+ const present = [];
+ for (const t of TABLES) {
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t)) continue;
+ present.push({ table: t, rows: db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n });
+ }
+ return present;
+}
+
+function report(present, log) {
+ if (!present.length) { log('none of the twelve chat tables is present; the drop is already applied (or these are fresh-schema databases)'); return; }
+ log(`${present.length} of ${TABLES.length} chat table(s) present:`);
+ for (const p of present) log(` ${p.table}: ${p.rows} row(s)`);
+}
+
+async function backup(db, file) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ await db.backup(file);
+ fs.chmodSync(file, 0o600);
+ return file;
+}
+
+async function main(argv = process.argv.slice(2), log = console.log) {
+ const opts = parseArgs(argv);
+ if (opts.help) { log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); return 0; }
+ const Database = require('better-sqlite3');
+ const DB_PATH = path.resolve(opts.db || require('../server/paths').dbPath());
+ const db = new Database(DB_PATH, { readonly: opts.mode === 'dry', fileMustExist: true });
+ try {
+ report(counts(db), log);
+ if (opts.mode === 'dry') return 0;
+ const file = opts.backup || path.join(path.dirname(DB_PATH), 'backups', `live-pre-chat-tables-drop-${new Date().toISOString().replace(/[:.]/g, '-')}.db`);
+ log(`backup: ${await backup(db, file)}`);
+ const res = require('../server/db/migrations').runOperator(db, ID);
+ log(`${ID}: ${res.outcome}${res.error ? ` (${res.error})` : ''}`);
+ if (res.outcome !== 'failed') report(counts(db), log);
+ return res.outcome === 'failed' ? 1 : 0;
+ } finally { db.close(); }
+}
+
+if (require.main === module) {
+ main().then((code) => process.exit(code), (err) => { console.error(err.message); process.exit(1); });
+}
+
+module.exports = { ID, TABLES, counts, parseArgs, main };
@@ -404,23 +404,8 @@ router.post('/delete-message', async (req, res) => {
@@ -404,23 +404,8 @@ router.post('/delete-message', async (req, res) => {
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
- // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached.
- if (delivery.ingress()) {
- await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });
- }
- else db.deleteChatMessage(parseInt(message_id), req.user.id);
-
- // Broadcast deletion to connected clients — reach the streamer's whole
- // channel room (all slots + offline viewers), plus the global feed.
- 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) {
- delivery.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);
- if (message.stream_id) delivery.forwardToGlobal(message.stream_id, delPayload);
- else delivery.broadcastGlobal(delPayload);
- } else {
- delivery.broadcastGlobal(delPayload);
- }
+ // Chat deletes the row and broadcasts the delete to every surface it reached.
+ await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });
delivery.logModeration({
delivery.logModeration({
scope_type: message.stream_id ? 'stream' : 'site',
scope_type: message.stream_id ? 'stream' : 'site',
@@ -458,34 +443,12 @@ router.post('/delete-user-messages', async (req, res) => {
@@ -458,34 +443,12 @@ router.post('/delete-user-messages', async (req, res) => {
}
}
}
}
- let ids = [];
- if (delivery.ingress()) {
- // Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.
- const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]
- : anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];
- const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };
- const r = await delivery.moderate(action, { ...subject, ...scoped });
- ids = (r && Array.isArray(r.ids)) ? r.ids : [];
- } else if (user_id) {
- ids = db.deleteUserChatMessages(parseInt(user_id), { streamId: scopedStreamId, deletedBy: req.user.id });
- } else if (anon_id) {
- ids = db.deleteAnonChatMessages(anon_id, { streamId: scopedStreamId, deletedBy: req.user.id });
- } else if (relay_username) {
- ids = db.deleteRelayUserMessages(relay_username, { streamId: scopedStreamId, deletedBy: req.user.id });
- }
-
- // Broadcast deletions to connected clients
- if (ids.length > 0 && !delivery.ingress()) {
- const deleteMsg = { type: 'delete-messages', ids };
- if (scopedStreamId) {
- const chanUid = db.getStreamById(scopedStreamId)?.user_id || null;
- delivery.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);
- delivery.forwardToGlobal(scopedStreamId, deleteMsg);
- } else {
- // Global deletion — Chat fans it out to the clients it holds; Live has none.
- delivery.broadcastGlobal(deleteMsg);
- }
- }
+ // Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.
+ const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]
+ : anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];
+ const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };
+ const r = await delivery.moderate(action, { ...subject, ...scoped });
+ const ids = (r && Array.isArray(r.ids)) ? r.ids : [];
const target = user_id ? `user ${user_id}` : anon_id ? `anon ${anon_id}` : `relay ${relay_username}`;
const target = user_id ? `user ${user_id}` : anon_id ? `anon ${anon_id}` : `relay ${relay_username}`;
delivery.logModeration({
delivery.logModeration({
@@ -531,10 +494,7 @@ router.post('/relay-user/hide', async (req, res) => {
@@ -531,10 +494,7 @@ router.post('/relay-user/hide', async (req, res) => {
// Unban / unhide — remove the ban for this exact relay identity only.
// Unban / unhide — remove the ban for this exact relay identity only.
if (action === 'unban' || action === 'unhide') {
if (action === 'unban' || action === 'unhide') {
- if (delivery.ingress()) {
- await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });
- }
- else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);
+ await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });
invalidateRelayHidden(channel_id || null);
invalidateRelayHidden(channel_id || null);
delivery.logModeration({
delivery.logModeration({
scope_type: channel_id ? 'channel' : 'site',
scope_type: channel_id ? 'channel' : 'site',
@@ -546,16 +506,7 @@ router.post('/relay-user/hide', async (req, res) => {
@@ -546,16 +506,7 @@ router.post('/relay-user/hide', async (req, res) => {
return res.json({ message: `[${platform}] ${external_username} unbanned` });
return res.json({ message: `[${platform}] ${external_username} unbanned` });
}
}
- if (delivery.ingress()) {
- await delivery.moderate('relay-hide', { channel_id: channel_id || undefined, platform, external_username, mode: action || 'hide', reason: reason || undefined, created_by: req.user.id });
- } else db.hideRelayUser({
- channelId: channel_id || null,
- platform,
- externalUsername: external_username,
- action: action || 'hide',
- reason,
- createdBy: req.user.id,
- });
+ await delivery.moderate('relay-hide', { channel_id: channel_id || undefined, platform, external_username, mode: action || 'hide', reason: reason || undefined, created_by: req.user.id });
invalidateRelayHidden(channel_id || null);
invalidateRelayHidden(channel_id || null);
delivery.logModeration({
delivery.logModeration({
@@ -582,8 +533,7 @@ router.delete('/relay-user/:id', async (req, res) => {
@@ -582,8 +533,7 @@ router.delete('/relay-user/:id', async (req, res) => {
const allowed = permissions.isGlobalModOrAbove(req.user)
const allowed = permissions.isGlobalModOrAbove(req.user)
|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
- if (delivery.ingress()) { await delivery.moderate('relay-unhide', { id: row.id }); }
- else db.unhideRelayUser(row.id);
+ await delivery.moderate('relay-unhide', { id: row.id });
invalidateRelayHidden(row.channel_id);
invalidateRelayHidden(row.channel_id);
delivery.logModeration({
delivery.logModeration({
@@ -660,39 +610,11 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
@@ -660,39 +610,11 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
return res.status(403).json({ error: 'Access denied' });
return res.status(403).json({ error: 'Access denied' });
}
}
- // The held rows this IP is about — from Chat when Chat is the authority (Live holds no
- // copy), else Live's own queue. In Chat mode they are not re-broadcast here: Chat releases
- // them and broadcasts; Live only needs the count for its answer and log.
- const pendingMsgs = delivery.ingress()
- ? (await chatReads.pendingIp(channelId, { limit: 500 })).filter((m) => m.ip_address === ip)
- : db.all("SELECT * FROM pending_ip_messages WHERE channel_id = ? AND ip_address = ? AND status = 'pending'", [channelId, ip]);
+ // The held rows this IP is about — Chat holds them; Live keeps no queue. Live only needs
+ // the count for its answer and log: Chat releases them and broadcasts.
+ const pendingMsgs = (await chatReads.pendingIp(channelId, { limit: 500 })).filter((m) => m.ip_address === ip);
- // Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).
- if (delivery.ingress()) {
- await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
- }
- else db.approveAllFromIp(channelId, ip, req.user.id);
-
- // Broadcast previously-held messages as real chat messages
- for (const msg of delivery.ingress() ? [] : pendingMsgs) {
- if (msg.stream_id) {
- const chatMsg = {
- type: 'chat',
- username: msg.username,
- user_id: msg.user_id || null,
- anon_id: msg.anon_id || null,
- role: msg.user_id ? 'user' : 'anon',
- message: msg.message,
- stream_id: msg.stream_id,
- is_global: false,
- profile_color: '#999',
- timestamp: msg.created_at,
- was_held: true,
- };
- delivery.broadcastToStream(msg.stream_id, chatMsg);
- delivery.forwardToGlobal(msg.stream_id, chatMsg);
- }
- }
+ await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
delivery.logModeration({
delivery.logModeration({
scope_type: 'channel',
scope_type: 'channel',
@@ -723,10 +645,7 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {
@@ -723,10 +645,7 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {
return res.status(403).json({ error: 'Access denied' });
return res.status(403).json({ error: 'Access denied' });
}
}
- if (delivery.ingress()) {
- await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
- }
- else db.denyAllFromIp(channelId, ip, req.user.id);
+ await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
delivery.logModeration({
delivery.logModeration({
scope_type: 'channel',
scope_type: 'channel',
@@ -758,13 +677,7 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {
@@ -758,13 +677,7 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {
return res.status(403).json({ error: 'Access denied' });
return res.status(403).json({ error: 'Access denied' });
}
}
- if (delivery.ingress()) {
- await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });
- } else db.reviewPendingIpMessage(parseInt(message_id), {
- status,
- reviewedBy: req.user.id,
- channelId,
- });
+ await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });
res.json({ message: `Message ${status}` });
res.json({ message: `Message ${status}` });
} catch (err) {
} catch (err) {
@@ -963,8 +876,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
@@ -963,8 +876,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
try {
try {
const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);
const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);
if (req.body?.reset) {
if (req.body?.reset) {
- if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey }); }
- else db.deleteTtsVoiceOverride(identityKey);
+ delivery.moderate('tts-voice-override', { identity_key: identityKey });
return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });
return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });
}
}
let params;
let params;
@@ -981,8 +893,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
@@ -981,8 +893,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };
params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };
}
}
const clamped = ttsEngine.clampVoiceParams(params);
const clamped = ttsEngine.clampVoiceParams(params);
- if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }
- else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);
+ delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id });
res.json({ isOverride: true, params: clamped });
res.json({ isOverride: true, params: clamped });
} catch (err) {
} catch (err) {
console.error('[Mod] tts-voice set error:', err.message);
console.error('[Mod] tts-voice set error:', err.message);
@@ -231,15 +231,8 @@ router.put('/users/:id', (req, res) => {
@@ -231,15 +231,8 @@ router.put('/users/:id', (req, res) => {
db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);
db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);
}
}
- // If display_name or username changed, update denormalized chat_messages.username
- // (chat_messages.username stores display_name at message creation time)
- if (display_name || username) {
- const freshUser = db.getUserById(req.params.id);
- if (freshUser) {
- const newChatName = freshUser.display_name || freshUser.username;
- db.run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, req.params.id]);
- }
- }
+ // No chat-name update here: OpenVibe.Chat owns the chat rows and keeps stored message
+ // names current via ctx_users; the delivery.invalidate() below tells it this user changed.
const user = db.getUserById(req.params.id);
const user = db.getUserById(req.params.id);
// Sanitize — never expose password_hash, email (legacy columns, WS-B task 2) or stream_key
// Sanitize — never expose password_hash, email (legacy columns, WS-B task 2) or stream_key
@@ -137,7 +137,7 @@ async function _momentContext(streamId, vodId) {
@@ -137,7 +137,7 @@ async function _momentContext(streamId, vodId) {
const clipTimes = await require('../media-proxy/lookups').clipStartTimes(streamId, vodId);
const clipTimes = await require('../media-proxy/lookups').clipStartTimes(streamId, vodId);
// Chat's own buckets since the stream began (offsets are relative to started_at, as before).
// Chat's own buckets since the stream began (offsets are relative to started_at, as before).
const started = (() => { try { const r = db.get('SELECT started_at FROM streams WHERE id = ?', [streamId]); return r && r.started_at ? Date.parse(String(r.started_at).replace(' ', 'T') + 'Z') : 0; } catch { return 0; } })();
const started = (() => { try { const r = db.get('SELECT started_at FROM streams WHERE id = ?', [streamId]); return r && r.started_at ? Date.parse(String(r.started_at).replace(' ', 'T') + 'Z') : 0; } catch { return 0; } })();
- const spikes = started > 0 ? (await chatReads.spikeOffsets(streamId, 30, 8, started) || []) : (db.getChatSpikeOffsets(streamId, 30, 8) || []);
+ const spikes = started > 0 ? (await chatReads.spikeOffsets(streamId, 30, 8, started) || []) : [];
// Non-speech sounds are strong moment candidates — an explosion or a burst of
// Non-speech sounds are strong moment candidates — an explosion or a burst of
// laughter marks a highlight as reliably as anything said out loud.
// laughter marks a highlight as reliably as anything said out loud.
let sounds = [];
let sounds = [];
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* stream_timeline_events (live transcript + sounds) stream_memories (frames)
* stream_timeline_events (live transcript + sounds) stream_memories (frames)
* streams.ai_overview / ai_title streamer_overviews
* streams.ai_overview / ai_title streamer_overviews
* app_state ai_whole_overview_<uid> (combined blurb)
* app_state ai_whole_overview_<uid> (combined blurb)
- * chat_messages (channel + relayed + bot lines) follows / subscriptions / stream_first_chats
+ * chat (channel + relayed + bot lines, via Chat) follows / subscriptions / first-chat state
* channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)
* channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)
* ai_viewer_threads (open conversations)
* ai_viewer_threads (open conversations)
*
*
@@ -205,8 +205,7 @@ function personBlock(line, channelUserId, settings) {
@@ -205,8 +205,7 @@ function personBlock(line, channelUserId, settings) {
}
}
// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the
// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the
// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat
// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat
- // read through a sync peek; a cold cache or a Chat outage answers "not first" and never
- // Live's own frozen copy.
+ // read through a sync peek; a cold cache or a Chat outage answers "not first".
const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');
if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');
} catch { /* */ }
} catch { /* */ }
@@ -45,54 +45,17 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})
@@ -45,54 +45,17 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})
const color = botColor(bot);
const color = botColor(bot);
const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;
const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;
const delivery = require('../../chat/chat-delivery');
const delivery = require('../../chat/chat-delivery');
- if (delivery.ingress()) {
- // Chat persists, broadcasts, mirrors to global and reads it aloud in one call; the real id arrives later.
- delivery.after(delivery.message({
- stream_id: streamId, channel_user_id: worker.userId || undefined, username: bot.username, message,
- message_type: 'chat', is_global: false, reply_to_id: replyToId || undefined, source_platform: 'ai',
- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
- mirror: true, frame: { role: 'user', profile_color: color, is_ai: true, filtered: false },
- tts: ttsOn ? { identity_key: `aibot:${bot.username.toLowerCase()}` } : undefined,
- }), (id) => forwardToPowerChat(worker, bot, message, id));
- try { db.touchChannelAiBot(bot.id); } catch { /* */ }
- return null;
- }
- let id = null;
- try {
- const res = db.saveChatMessage({
- stream_id: streamId,
- channel_user_id: worker.userId,
- user_id: null,
- username: bot.username,
- message,
- message_type: 'chat',
- is_global: false,
- reply_to_id: replyToId || null,
- source_platform: 'ai', // marker → excluded from chat-AI feeds
- metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
- });
- id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;
- } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }
-
- const chatMsg = {
- type: 'chat', id: id || undefined,
- username: bot.username, core_username: null,
- user_id: null, anon_id: null, role: 'user',
- message, stream_id: streamId, is_global: false,
- reply_to_id: replyToId || null,
- avatar_url: null, profile_color: color,
- is_ai: true, source_platform: 'ai',
- filtered: false, timestamp: new Date().toISOString(),
- };
- try {
- 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);
+ // Chat persists, broadcasts, mirrors to global and reads it aloud in one call; the real id
+ // arrives later. Live keeps no chat table, so there is no local id to return.
+ delivery.after(delivery.message({
+ stream_id: streamId, channel_user_id: worker.userId || undefined, username: bot.username, message,
+ message_type: 'chat', is_global: false, reply_to_id: replyToId || undefined, source_platform: 'ai',
+ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
+ mirror: true, frame: { role: 'user', profile_color: color, is_ai: true, filtered: false },
+ tts: ttsOn ? { identity_key: `aibot:${bot.username.toLowerCase()}` } : undefined,
+ }), (id) => forwardToPowerChat(worker, bot, message, id));
try { db.touchChannelAiBot(bot.id); } catch { /* */ }
try { db.touchChannelAiBot(bot.id); } catch { /* */ }
- return id;
+ return null;
}
}
function forwardToPowerChat(worker, bot, message, id) {
function forwardToPowerChat(worker, bot, message, id) {
@@ -21,9 +21,9 @@
@@ -21,9 +21,9 @@
* kept rows point at nobody.
* kept rows point at nobody.
*
*
* Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and
* Never touched: the frozen tables (vods, clips, pastes, paste_likes, paste_comments, comments), which Media and
- * Community own and erase themselves. The five chat tables (channel_moderators, channel_moderation_settings,
- * user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3) and erased by its eraser
- * (Chat server/chat/account-data.js), never here.
+ * Community own and erase themselves, and every OpenVibe.Chat table (chat_messages, the moderation, DM, sound,
+ * relay, TTS overrides and first-chat tables of roadmap T3): Chat's eraser (Chat server/chat/account-data.js)
+ * deletes the person's rows there, never this one. Live keeps no copy of any of them since 2026-10-05.
*
*
* Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.
* Applied once per export or deletion (account_data_events); a redelivery resends only what did not reach Network.
*/
*/
@@ -35,8 +35,8 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -35,8 +35,8 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
const FROZEN = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments']);
const FROZEN = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments']);
const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'moderation_actions', 'bans',
const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'moderation_actions', 'bans',
'token_revocations', 'verification_keys', 'subject_merges', 'account_data_events']);
'token_revocations', 'verification_keys', 'subject_merges', 'account_data_events']);
-// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: their own chat lines,
-// sign-in IPs and VPN requests.
+// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs, VPN
+// requests, and their lines in the legacy chat_messages copy while it still exists (see LEGACY_CHAT_COPIES).
const OVERRIDE_DELETE = new Set(['chat_messages.user_id', 'ip_log.user_id', 'vpn_approvals.user_id']);
const OVERRIDE_DELETE = new Set(['chat_messages.user_id', 'ip_log.user_id', 'vpn_approvals.user_id']);
// Copies of the person's name kept beside a person column: cleared wherever the row stays.
// Copies of the person's name kept beside a person column: cleared wherever the row stays.
const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];
const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];
@@ -45,8 +45,13 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_
@@ -45,8 +45,13 @@ const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_
const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;
const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;
const ROW_LIMIT = 2000;
const ROW_LIMIT = 2000;
const PART_BUDGET = 18 * 1024 * 1024;
const PART_BUDGET = 18 * 1024 * 1024;
-// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.
+// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them (dropped in N+2/N+3).
const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
+// Live's legacy copies of the twelve OpenVibe.Chat tables (a read mirror until 2026-10-05; dropped by
+// op_003_drop_chat_tables). Never exported (Chat contributes the chat part), but while a copy still exists here a
+// deletion erases the person from it exactly as before, so a stale copy never outlives their erasure.
+const LEGACY_CHAT_COPIES = new Set(['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
+ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions']);
function ensureSchema(d) {
function ensureSchema(d) {
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
@@ -104,6 +109,7 @@ function exportPart(d, userId) {
@@ -104,6 +109,7 @@ function exportPart(d, userId) {
bytes += JSON.stringify(profile).length;
bytes += JSON.stringify(profile).length;
const other = {};
const other = {};
for (const [t, cols] of personColumns(d)) {
for (const [t, cols] of personColumns(d)) {
+ if (CHAT_TABLES.has(t) || LEGACY_CHAT_COPIES.has(t)) continue; // Chat's rows: Chat contributes them to the chat part of the export
const keep = d.prepare(`PRAGMA table_info(${q(t)})`).all().map((c) => c.name).filter((c) => !SECRET_COL.test(c));
const keep = d.prepare(`PRAGMA table_info(${q(t)})`).all().map((c) => c.name).filter((c) => !SECRET_COL.test(c));
if (!keep.length) continue;
if (!keep.length) continue;
const where = cols.map((c) => `${q(c.col)} = ?`).join(' OR ');
const where = cols.map((c) => `${q(c.col)} = ?`).join(' OR ');
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
* runs no chat server in any mode;
* runs no chat server in any mode;
* - reads chat stats, queues and history from Chat's internal read API
* - reads chat stats, queues and history from Chat's internal read API
* (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
* (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
- * retired on 2026-10-05 and Live keeps its chat tables only until a later change drops them;
+ * retired on 2026-10-05, Live keeps no copy of Chat's tables, and its twelve are dropped by the
+ * operator migration op_003_drop_chat_tables (scripts/chat-tables-drop.js);
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
* 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.
* is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>
* message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>
* event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user | Chat's own kinds }
* event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user | Chat's own kinds }
* moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>
* moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>
- * logModeration(entry) a moderation-log row (db.logModerationAction shape)
+ * logModeration(entry) a moderation-log row (Chat's moderation_actions shape)
* disconnect({ userId, ip, streamId })
* disconnect({ userId, ip, streamId })
* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
@@ -78,8 +78,9 @@ function moderate(action, fields, { key } = {}) {
@@ -78,8 +78,9 @@ function moderate(action, fields, { key } = {}) {
}
}
const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];
const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];
+// Chat owns the moderation log (its moderation_actions; Live keeps no copy), so this always goes
+// through Chat's ingress — in every mode, like every other moderation write.
function logModeration(entry) {
function logModeration(entry) {
- if (!ingress()) return db.logModerationAction(entry);
const fields = {};
const fields = {};
for (const k of LOG_FIELDS) if (entry[k] != null) fields[k] = entry[k];
for (const k of LOG_FIELDS) if (entry[k] != null) fields[k] = entry[k];
if (typeof fields.scope_id === 'string' && fields.scope_type !== 'room' && /^\d+$/.test(fields.scope_id)) fields.scope_id = Number(fields.scope_id);
if (typeof fields.scope_id === 'string' && fields.scope_type !== 'room' && /^\d+$/.test(fields.scope_id)) fields.scope_id = Number(fields.scope_id);
@@ -4,13 +4,13 @@
@@ -4,13 +4,13 @@
* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,
* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,
* pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,
* pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,
* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
- * internal read API (server/chat/chat-client.js). Chat's read mirror was retired on 2026-10-05
- * (Chat #25 removed the sender, Live dropped the receiver), so Live's own copies of the tables Chat
- * owns are frozen and NO remote answer falls back to them.
+ * internal read API (server/chat/chat-client.js). Live keeps no copy of any of them since 2026-10-05
+ * (the read mirror was retired on both sides; the tables themselves are dropped by migration
+ * op_003_drop_chat_tables), so outside chat mode every one of these answers null / empty.
*
*
* Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
* Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
- * same call answers from Live's own tables — the caller never has to know which. Unsetting
- * CHAT_AUTHORITY is not a rollback lever: Live runs no chat server any more either way.
+ * same call answers null / empty — the caller never has to know which. Unsetting CHAT_AUTHORITY is
+ * not a rollback lever: Live runs no chat server any more either way.
*
*
* Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek
* Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
@@ -41,10 +41,9 @@ let lastLog = null;
@@ -41,10 +41,9 @@ let lastLog = null;
function drill() { try { return require('../drill').enabled; } catch { return false; } }
function drill() { try { return require('../drill').enabled; } catch { return false; } }
function remote() { return isRemote() && !drill(); }
function remote() { return isRemote() && !drill(); }
function local() { return require('../db/database'); }
function local() { return require('../db/database'); }
-/** Run a local (Live-table) read for a synchronous caller, answering `dflt` if it throws. */
+/** Run a Live-table read for a synchronous caller, answering `dflt` if it throws. Still used for
+ * the non-chat home metrics (users, follows, …), which Live owns. */
function safeLocal(fn, dflt) { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } }
function safeLocal(fn, dflt) { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } }
-/** Epoch ms → the UTC 'YYYY-MM-DD HH:MM:SS' text chat_messages.timestamp stores. */
-const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
function note(what, err) {
function note(what, err) {
const m = `Chat read ${what}: ${(err && err.message) || err}`;
const m = `Chat read ${what}: ${(err && err.message) || err}`;
@@ -94,9 +93,9 @@ function rememberFailure(key) {
@@ -94,9 +93,9 @@ function rememberFailure(key) {
}
}
/**
/**
- * Async answer: Chat (remote) or Live's own tables (local), cached. A failure answers the last
- * good value, else null; with `strict` it throws `Chat unavailable` instead (the moderation
- * console). `ttl: 0` always reads live and never caches. An error carrying a `status` (a
+ * Async answer: Chat when it is the authority, else the (empty) local answer, cached. A failure
+ * answers the last good value, else null; with `strict` it throws `Chat unavailable` instead (the
+ * moderation console). `ttl: 0` always reads live and never caches. An error carrying a `status` (a
* deliberate "not supported") is handed to the caller as it is.
* deliberate "not supported") is handed to the caller as it is.
*/
*/
function load(key, remoteFn, localFn, { ttl = CACHE_TTL_MS, strict = false, ttlFor = null } = {}) {
function load(key, remoteFn, localFn, { ttl = CACHE_TTL_MS, strict = false, ttlFor = null } = {}) {
@@ -144,13 +143,9 @@ function peek(key, remoteFn, localFn, opts) {
@@ -144,13 +143,9 @@ function peek(key, remoteFn, localFn, opts) {
}
}
// ── Stats ─────────────────────────────────────────────────────────────────────
// ── Stats ─────────────────────────────────────────────────────────────────────
-function localSiteStats() {
- const db = local();
- const r = db.get(`SELECT COUNT(*) AS messages,
- COUNT(DISTINCT COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)) AS chatters
- FROM chat_messages WHERE is_deleted = 0`) || {};
- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0 };
-}
+// Live keeps no chat_messages: outside chat mode these reads answer zero/empty. The named local*
+// functions stay so load()/peek() keep one shape and the answers are explicit, not absent.
+function localSiteStats() { return { messages: 0, chatters: 0 }; }
async function remoteSiteStats() {
async function remoteSiteStats() {
const out = await client.readStats({ kind: 'site' });
const out = await client.readStats({ kind: 'site' });
if (!out) throw unavailable('site stats');
if (!out) throw unavailable('site stats');
@@ -158,16 +153,8 @@ async function remoteSiteStats() {
@@ -158,16 +153,8 @@ async function remoteSiteStats() {
}
}
/** { messages, chatters } across the whole site. */
/** { messages, chatters } across the whole site. */
function siteStats() { return load('site', remoteSiteStats, localSiteStats); }
function siteStats() { return load('site', remoteSiteStats, localSiteStats); }
-/**
- * The home-stats snapshot answers with `messages` only, so its local (rollback / dev) answer is a
- * plain COUNT(*): the distinct-chatter scan `siteStats()` runs is a full scan this synchronous peek
- * would otherwise force on every home-stats compute. In chat mode it answers Chat's cached value.
- */
-function localSiteStatsMessages() {
- const db = local();
- const r = db.get('SELECT COUNT(*) AS messages FROM chat_messages WHERE is_deleted = 0') || {};
- return { messages: Number(r.messages) || 0 };
-}
+/** The home-stats snapshot's shape: outside chat mode there is no count to answer. */
+function localSiteStatsMessages() { return { messages: 0 }; }
function siteStatsPeek() { return peek('site', remoteSiteStats, localSiteStatsMessages); }
function siteStatsPeek() { return peek('site', remoteSiteStats, localSiteStatsMessages); }
// ── Home daily series (the hero charts) ──────────────────────────────────────
// ── Home daily series (the hero charts) ──────────────────────────────────────
@@ -202,7 +189,11 @@ async function remoteHomeSeries(metric, days) {
@@ -202,7 +189,11 @@ async function remoteHomeSeries(metric, days) {
return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map((p) => p.value)), before: pick(beforeOut), prev_total: pick(prevOut) };
return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map((p) => p.value)), before: pick(beforeOut), prev_total: pick(prevOut) };
}
}
-/** The daily series behind a hero stat, from Chat (remote) or Live's own tables (local), cached. */
+/**
+ * The daily series behind a hero stat, from Chat (remote) or Live's own tables (local), cached.
+ * Only the two chat metrics come through here; outside chat mode they answer null (Live keeps no
+ * chat_messages — homeSeriesLocal has no entry for them).
+ */
function homeSeries(metric, days) {
function homeSeries(metric, days) {
const d = clampSeriesDays(days);
const d = clampSeriesDays(days);
const key = `hs:${metric}:${d}:${utcDayStart(Date.now())}`;
const key = `hs:${metric}:${d}:${utcDayStart(Date.now())}`;
@@ -210,9 +201,9 @@ function homeSeries(metric, days) {
@@ -210,9 +201,9 @@ function homeSeries(metric, days) {
return load(key, () => remoteHomeSeries(metric, d), localFn).then((v) => v || safeLocal(localFn, null));
return load(key, () => remoteHomeSeries(metric, d), localFn).then((v) => v || safeLocal(localFn, null));
}
}
/**
/**
- * The same for the synchronous home-stats series route: the last good answer (or, on a cold cache,
- * Live's mirror-filled tables) while Chat refreshes in the background — never zeros just because
- * Chat has not answered yet.
+ * The same for the synchronous home-stats series route: the last good answer while Chat refreshes
+ * in the background — never zeros just because Chat has not answered yet. Outside chat mode there
+ * is nothing to answer from: null.
*/
*/
function homeSeriesPeek(metric, days) {
function homeSeriesPeek(metric, days) {
const d = clampSeriesDays(days);
const d = clampSeriesDays(days);
@@ -220,8 +211,8 @@ function homeSeriesPeek(metric, days) {
@@ -220,8 +211,8 @@ function homeSeriesPeek(metric, days) {
const localFn = () => local().homeSeriesLocal(metric, d);
const localFn = () => local().homeSeriesLocal(metric, d);
const v = peek(key, () => remoteHomeSeries(metric, d), localFn);
const v = peek(key, () => remoteHomeSeries(metric, d), localFn);
if (v) return v;
if (v) return v;
- if (!remote()) return null; // the peek already answered from Live's own tables
- return safeLocal(localFn, null); // cold / Chat unreachable: Live's mirror-filled tables
+ if (!remote()) return null; // outside chat mode Live keeps no chat series
+ return safeLocal(localFn, null); // cold / Chat unreachable
}
}
/** The busiest chatters of a room (or the site, when neither stream nor channel is given), newest window first. */
/** The busiest chatters of a room (or the site, when neither stream nor channel is given), newest window first. */
@@ -231,41 +222,16 @@ function topChatters({ since, streamId, channelUserId, limit = 10 } = {}) {
@@ -231,41 +222,16 @@ function topChatters({ since, streamId, channelUserId, limit = 10 } = {}) {
const out = await client.readStats({ kind: 'channel-top', since, stream_id: streamId, channel_user_id: channelUserId, limit });
const out = await client.readStats({ kind: 'channel-top', since, stream_id: streamId, channel_user_id: channelUserId, limit });
if (!out || !Array.isArray(out.top_chatters)) throw unavailable('top chatters');
if (!out || !Array.isArray(out.top_chatters)) throw unavailable('top chatters');
return out.top_chatters;
return out.top_chatters;
- }, () => {
- const db = local();
- const where = ['c.is_deleted = 0', "c.message_type <> 'system'", 'COALESCE(u.is_banned, 0) = 0'];
- const params = [];
- if (since) { where.push('c.timestamp >= ?'); params.push(new Date(since).toISOString().slice(0, 19).replace('T', ' ')); }
- if (streamId) { where.push('c.stream_id = ?'); params.push(streamId); }
- if (channelUserId) { where.push('c.channel_user_id = ?'); params.push(channelUserId); }
- params.push(limit);
- return db.all(`SELECT c.user_id, MAX(c.username) AS username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS count
- FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id
- WHERE ${where.join(' AND ')}
- GROUP BY c.user_id, CASE WHEN c.user_id IS NULL THEN c.username END
- ORDER BY count DESC LIMIT ?`, params) || [];
- });
+ }, () => []);
}
}
// ── Stats over a window / one room ───────────────────────────────────────────
// ── Stats over a window / one room ───────────────────────────────────────────
// Chat's stats read takes since/until plus at most one of stream_id / channel_user_id and answers
// Chat's stats read takes since/until plus at most one of stream_id / channel_user_id and answers
// { messages, chatters } for that window (kind 'stream' also answers the soundboard count). The
// { messages, chatters } for that window (kind 'stream' also answers the soundboard count). The
// home-stats snapshot and the digest ask site-wide, the recap and stream analytics ask per stream,
// home-stats snapshot and the digest ask site-wide, the recap and stream analytics ask per stream,
-// the star picker per channel. The local (rollback / dev) answer is the same SQL Live's own chat
-// runs; a peek answers the last good value while Chat refreshes in the background.
-function localWindowStats({ since, until, streamId, channelUserId } = {}) {
- const db = local();
- const where = ['is_deleted = 0'];
- const params = [];
- if (since != null) { where.push('timestamp >= ?'); params.push(sqlTime(since)); }
- if (until != null) { where.push('timestamp < ?'); params.push(sqlTime(until)); }
- if (streamId != null) { where.push('stream_id = ?'); params.push(Number(streamId)); }
- if (channelUserId != null) { where.push('channel_user_id = ?'); params.push(Number(channelUserId)); }
- const r = db.get(`SELECT COUNT(*) AS messages,
- COUNT(DISTINCT COALESCE('u:' || user_id, 'a:' || anon_id, source_platform || ':' || username)) AS chatters
- FROM chat_messages WHERE ${where.join(' AND ')}`, params) || {};
- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0 };
-}
+// the star picker per channel. Outside chat mode the answer is zero (Live keeps no chat table); a
+// peek answers the last good value while Chat refreshes in the background.
+function localWindowStats() { return { messages: 0, chatters: 0 }; }
async function remoteWindowStats({ since, until, streamId, channelUserId } = {}) {
async function remoteWindowStats({ since, until, streamId, channelUserId } = {}) {
const out = await client.readStats({ kind: 'site', since, until, stream_id: streamId, channel_user_id: channelUserId });
const out = await client.readStats({ kind: 'site', since, until, stream_id: streamId, channel_user_id: channelUserId });
if (!out) throw unavailable('window stats');
if (!out) throw unavailable('window stats');
@@ -276,7 +242,7 @@ const HOUR_BUCKET_MS = 60 * 60_000; // a 7/14/30-day window snaps to the
@@ -276,7 +242,7 @@ const HOUR_BUCKET_MS = 60 * 60_000; // a 7/14/30-day window snaps to the
/**
/**
* Snap a window's since/until to a stable bucket BEFORE both the cache key and the request use them.
* Snap a window's since/until to a stable bucket BEFORE both the cache key and the request use them.
* Callers build these from Date.now(), which changes on every compute, so an exact key is cold on
* Callers build these from Date.now(), which changes on every compute, so an exact key is cold on
- * every call: Chat is never actually consulted (the peek answers a mirror scan instead), each call
+ * every call: Chat is never actually consulted (the peek answers a stale value instead), each call
* mints a new cache entry, and the request it sends is thrown away. A minute — or an hour once the
* mints a new cache entry, and the request it sends is thrown away. A minute — or an hour once the
* window spans a day or more — is far finer than these coarse counts need and keeps the key stable
* window spans a day or more — is far finer than these coarse counts need and keeps the key stable
* well past CACHE_TTL_MS. The request is sent with the bucketed values so it matches its own key.
* well past CACHE_TTL_MS. The request is sent with the bucketed values so it matches its own key.
@@ -296,7 +262,7 @@ function windowStats(o = {}) {
@@ -296,7 +262,7 @@ function windowStats(o = {}) {
}
}
/**
/**
* The same, for a caller that cannot await (the home-stats snapshot, the digest). Answers the last
* The same, for a caller that cannot await (the home-stats snapshot, the digest). Answers the last
- * good value, else null — never a synchronous mirror scan, so the hero never presents Live's stale
+ * good value, else null — never a synchronous local scan, so the hero never presents a local
* count as Chat's; the caller decides what an unknown window means.
* count as Chat's; the caller decides what an unknown window means.
*/
*/
function windowStatsPeek(o = {}) {
function windowStatsPeek(o = {}) {
@@ -304,14 +270,7 @@ function windowStatsPeek(o = {}) {
@@ -304,14 +270,7 @@ function windowStatsPeek(o = {}) {
return peek(windowKey(b), () => remoteWindowStats(b), () => localWindowStats(b));
return peek(windowKey(b), () => remoteWindowStats(b), () => localWindowStats(b));
}
}
-function localStreamStats(streamId) {
- const db = local();
- const r = db.get(`SELECT COUNT(*) AS messages,
- COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS chatters,
- SUM(CASE WHEN message_type = 'soundboard' THEN 1 ELSE 0 END) AS sounds
- FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0`, [Number(streamId)]) || {};
- return { messages: Number(r.messages) || 0, chatters: Number(r.chatters) || 0, sounds: Number(r.sounds) || 0 };
-}
+function localStreamStats() { return { messages: 0, chatters: 0, sounds: 0 }; }
async function remoteStreamStats(streamId) {
async function remoteStreamStats(streamId) {
const out = await client.readStats({ kind: 'stream', stream_id: Number(streamId) });
const out = await client.readStats({ kind: 'stream', stream_id: Number(streamId) });
if (!out) throw unavailable('stream stats');
if (!out) throw unavailable('stream stats');
@@ -341,28 +300,22 @@ async function remoteChannelMaxId(channelUserId) {
@@ -341,28 +300,22 @@ async function remoteChannelMaxId(channelUserId) {
/**
/**
* The newest chat id in a channel — Chat's id, so the AI context delta (channelMessagesPeek reads
* The newest chat id in a channel — Chat's id, so the AI context delta (channelMessagesPeek reads
* Chat's ids) stays aligned. `channelMaxId` awaits Chat (and warms the peek); `channelMaxIdPeek` is
* Chat's ids) stays aligned. `channelMaxId` awaits Chat (and warms the peek); `channelMaxIdPeek` is
- * for the caller that cannot await and answers the last good id, else Live's own table.
+ * for the caller that cannot await and answers the last good id, else 0 (Live keeps no table).
*/
*/
function channelMaxId(channelUserId) {
function channelMaxId(channelUserId) {
const id = Number(channelUserId) || 0;
const id = Number(channelUserId) || 0;
if (!id) return Promise.resolve(0);
if (!id) return Promise.resolve(0);
- return load(`cmid:${id}`, () => remoteChannelMaxId(id), () => local().getMaxChatMessageIdForChannel(id))
- .then((v) => (v != null ? Number(v) || 0 : safeLocal(() => local().getMaxChatMessageIdForChannel(id), 0)));
+ return load(`cmid:${id}`, () => remoteChannelMaxId(id), () => 0)
+ .then((v) => (v != null ? Number(v) || 0 : 0));
}
}
function channelMaxIdPeek(channelUserId) {
function channelMaxIdPeek(channelUserId) {
const id = Number(channelUserId) || 0;
const id = Number(channelUserId) || 0;
if (!id) return 0;
if (!id) return 0;
- const v = peek(`cmid:${id}`, () => remoteChannelMaxId(id), () => local().getMaxChatMessageIdForChannel(id));
- if (v != null) return Number(v) || 0;
- return safeLocal(() => local().getMaxChatMessageIdForChannel(id), 0);
+ const v = peek(`cmid:${id}`, () => remoteChannelMaxId(id), () => 0);
+ return v != null ? Number(v) || 0 : 0;
}
}
-function localUserMessageCount(userId) {
- const r = local().get(`SELECT COUNT(*) AS c FROM chat_messages
- WHERE user_id = ? AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [Number(userId)]);
- return Number(r && r.c) || 0;
-}
+function localUserMessageCount() { return 0; }
async function remoteUserMessageCount(userId) {
async function remoteUserMessageCount(userId) {
const out = await client.readStats({ kind: 'user', user_id: Number(userId) });
const out = await client.readStats({ kind: 'user', user_id: Number(userId) });
if (!out) throw unavailable('user message count');
if (!out) throw unavailable('user message count');
@@ -370,8 +323,8 @@ async function remoteUserMessageCount(userId) {
@@ -370,8 +323,8 @@ async function remoteUserMessageCount(userId) {
}
}
/**
/**
* A user's non-deleted chat total, for a caller that cannot await (the profile card). Answers the
* A user's non-deleted chat total, for a caller that cannot await (the profile card). Answers the
- * last good value, else null when neither Chat nor (in dev / rollback) Live's table answered — the
- * card omits the count then rather than presenting a cold mirror number as real.
+ * last good value, else null when Chat did not answer — the card omits the count then rather than
+ * presenting a cold zero as real.
*/
*/
function userMessageCountPeek(userId) {
function userMessageCountPeek(userId) {
const id = Number(userId) || 0;
const id = Number(userId) || 0;
@@ -412,7 +365,7 @@ function searchMessages({ query = '', userId = null, anonId = null, username = n
@@ -412,7 +365,7 @@ function searchMessages({ query = '', userId = null, anonId = null, username = n
if (query) { const q = String(query).toLowerCase(); rows = rows.filter((m) => String(m.message || '').toLowerCase().includes(q)); }
if (query) { const q = String(query).toLowerCase(); rows = rows.filter((m) => String(m.message || '').toLowerCase().includes(q)); }
const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);
const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);
return { messages: page, total: rows.length + offset };
return { messages: page, total: rows.length + offset };
- }, () => local().searchChatMessages({ query, userId, anonId, username, streamId, limit, offset }), { strict: true });
+ }, () => ({ messages: [], total: 0 }), { strict: true });
}
}
/** One message by id (the moderation delete path checks the row it is about). Live, never cached. */
/** One message by id (the moderation delete path checks the row it is about). Live, never cached. */
@@ -422,7 +375,7 @@ function messageById(id) {
@@ -422,7 +375,7 @@ function messageById(id) {
const out = await client.readMessages({ id: Number(id) });
const out = await client.readMessages({ id: Number(id) });
if (!out || !Array.isArray(out.messages)) throw unavailable('message');
if (!out || !Array.isArray(out.messages)) throw unavailable('message');
return out.messages[0] || null;
return out.messages[0] || null;
- }, () => local().getChatMessageById(Number(id)) || null, { ttl: 0, strict: true });
+ }, () => null, { ttl: 0, strict: true });
}
}
/** Newest `limit` messages of a channel, oldest→newest (the AI persona's chat delta reads the cache). */
/** Newest `limit` messages of a channel, oldest→newest (the AI persona's chat delta reads the cache). */
@@ -434,7 +387,7 @@ function channelMessages(channelUserId, limit = 40) {
@@ -434,7 +387,7 @@ function channelMessages(channelUserId, limit = 40) {
const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });
const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });
if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');
if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');
return out.messages.slice().reverse(); // Chat answers newest-first; the caller wants oldest-first
return out.messages.slice().reverse(); // Chat answers newest-first; the caller wants oldest-first
- }, () => local().getChannelChatSince(id, 0, limit) || []);
+ }, () => []);
}
}
function channelMessagesPeek(channelUserId, limit = 40) {
function channelMessagesPeek(channelUserId, limit = 40) {
const id = Number(channelUserId) || 0;
const id = Number(channelUserId) || 0;
@@ -443,7 +396,7 @@ function channelMessagesPeek(channelUserId, limit = 40) {
@@ -443,7 +396,7 @@ function channelMessagesPeek(channelUserId, limit = 40) {
const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });
const out = await client.readMessages({ channel_user_id: id, limit, types: ['chat', 'donation'] });
if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');
if (!out || !Array.isArray(out.messages)) throw unavailable('channel messages');
return out.messages.slice().reverse();
return out.messages.slice().reverse();
- }, () => local().getChannelChatSince(id, 0, limit) || []) || [];
+ }, () => []) || [];
}
}
/** A user's chat history page (admin console): { messages, total }. */
/** A user's chat history page (admin console): { messages, total }. */
@@ -458,7 +411,7 @@ function userHistory(userId, { limit = 50, offset = 0 } = {}) {
@@ -458,7 +411,7 @@ function userHistory(userId, { limit = 50, offset = 0 } = {}) {
const rows = offset ? out.messages.slice(offset, offset + limit) : out.messages.slice(0, limit);
const rows = offset ? out.messages.slice(offset, offset + limit) : out.messages.slice(0, limit);
// Chat's read API answers a page, not a count; a lower bound keeps "load more" honest.
// Chat's read API answers a page, not a count; a lower bound keeps "load more" honest.
return { messages: rows, total: rows.length + offset };
return { messages: rows, total: rows.length + offset };
- }, () => local().getUserChatHistory(id, limit, offset), { strict: true });
+ }, () => ({ messages: [], total: 0 }), { strict: true });
}
}
// A relay row's username is stored prefixed: "[Label] name". The labels are the ones the relay
// A relay row's username is stored prefixed: "[Label] name". The labels are the ones the relay
@@ -480,7 +433,7 @@ function relayHistory(platform, username, { limit = 50, offset = 0, query = '' }
@@ -480,7 +433,7 @@ function relayHistory(platform, username, { limit = 50, offset = 0, query = '' }
const rows = out.messages.filter((m) => (m.source_platform || '') === String(platform).toLowerCase());
const rows = out.messages.filter((m) => (m.source_platform || '') === String(platform).toLowerCase());
const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);
const page = offset ? rows.slice(offset, offset + limit) : rows.slice(0, limit);
return { messages: page, total: rows.length + offset };
return { messages: page, total: rows.length + offset };
- }, () => local().getRelayUserChatHistory(platform, username, { limit, offset, query }));
+ }, () => ({ messages: [], total: 0 }));
}
}
/** A chatter's recent messages in ONE channel (an AI-viewer clone source); relay matches by handle. */
/** A chatter's recent messages in ONE channel (an AI-viewer clone source); relay matches by handle. */
@@ -497,7 +450,7 @@ function channelSamples(channelUserId, { userId = null, relay = null, limit = 30
@@ -497,7 +450,7 @@ function channelSamples(channelUserId, { userId = null, relay = null, limit = 30
const out = await client.readMessages({ user_id: Number(userId), limit: Math.min(500, limit * 4) });
const out = await client.readMessages({ user_id: Number(userId), limit: Math.min(500, limit * 4) });
if (!out || !Array.isArray(out.messages)) throw unavailable('channel samples');
if (!out || !Array.isArray(out.messages)) throw unavailable('channel samples');
return out.messages.filter((m) => Number(m.channel_user_id) === Number(channelUserId)).slice(0, limit);
return out.messages.filter((m) => Number(m.channel_user_id) === Number(channelUserId)).slice(0, limit);
- }, () => local().getChatSamplesInChannel(channelUserId, { userId, relay, limit }));
+ }, () => []);
}
}
/** A channel's chat volume over the last `windowSec`, in `bucketSec` buckets: [{ count, tsEpoch }]. */
/** A channel's chat volume over the last `windowSec`, in `bucketSec` buckets: [{ count, tsEpoch }]. */
@@ -510,7 +463,7 @@ function liveChatBuckets(streamId, windowSec = 150, bucketSec = 15) {
@@ -510,7 +463,7 @@ function liveChatBuckets(streamId, windowSec = 150, bucketSec = 15) {
const out = await client.readTimeline({ stream_id: id, since, bucket_ms: bucketSec * 1000 });
const out = await client.readTimeline({ stream_id: id, since, bucket_ms: bucketSec * 1000 });
if (!out || !Array.isArray(out.buckets)) throw unavailable('timeline');
if (!out || !Array.isArray(out.buckets)) throw unavailable('timeline');
return out.buckets.map((b) => ({ count: Number(b.count) || 0, tsEpoch: Math.round(Number(b.t) / 1000) }));
return out.buckets.map((b) => ({ count: Number(b.count) || 0, tsEpoch: Math.round(Number(b.t) / 1000) }));
- }, () => local().getLiveChatBuckets(id, windowSec, bucketSec) || []);
+ }, () => []);
}
}
/** The message texts of a stream in the last `sinceSec` seconds (AI clip confirmation), oldest→newest. */
/** The message texts of a stream in the last `sinceSec` seconds (AI clip confirmation), oldest→newest. */
@@ -526,7 +479,7 @@ function recentChatText(streamId, sinceSec = 120, limit = 40) {
@@ -526,7 +479,7 @@ function recentChatText(streamId, sinceSec = 120, limit = 40) {
.filter((m) => m.message && Date.parse(String(m.timestamp).replace(' ', 'T') + 'Z') >= floor)
.filter((m) => m.message && Date.parse(String(m.timestamp).replace(' ', 'T') + 'Z') >= floor)
.map((m) => String(m.message))
.map((m) => String(m.message))
.reverse();
.reverse();
- }, () => local().getRecentChatText(id, sinceSec, limit) || []);
+ }, () => []);
}
}
/** The busiest `bucketSec` time-buckets of a stream, as offsets from `sinceMs`: [{ offset, count }]. */
/** The busiest `bucketSec` time-buckets of a stream, as offsets from `sinceMs`: [{ offset, count }]. */
@@ -542,21 +495,18 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {
@@ -542,21 +495,18 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {
.map((b) => ({ offset: Math.max(0, Math.round((Number(b.t) - base) / 1000 / bucketSec) * bucketSec), count: Number(b.count) || 0 }))
.map((b) => ({ offset: Math.max(0, Math.round((Number(b.t) - base) / 1000 / bucketSec) * bucketSec), count: Number(b.count) || 0 }))
.sort((a, b) => b.count - a.count || a.offset - b.offset)
.sort((a, b) => b.count - a.count || a.offset - b.offset)
.slice(0, topN);
.slice(0, topN);
- }, () => local().getChatSpikeOffsets(id, bucketSec, topN) || []);
+ }, () => []);
}
}
// ── First chat (the welcome check) ───────────────────────────────────────────
// ── First chat (the welcome check) ───────────────────────────────────────────
// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay
// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay
// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached
// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached
// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller
// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller
-// uses firstChatPeek. In chat mode a cold cache or a Chat outage answers false — Live's own copy is
-// a frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
+// uses firstChatPeek. A cold cache or a Chat outage answers false — Live keeps no copy of the table.
const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stable
const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stable
const FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat
const FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat
-function firstChatLocal(identity, channelUserId) {
- return safeLocal(() => local().isFirstChatInChannel(identity, channelUserId), false);
-}
+function firstChatLocal() { return false; }
function firstChatRemote(channelUserId, identity) {
function firstChatRemote(channelUserId, identity) {
return async () => {
return async () => {
const out = await client.readFirstChat({ channel_id: channelUserId, identity });
const out = await client.readFirstChat({ channel_id: channelUserId, identity });
@@ -569,23 +519,23 @@ function firstChat(channelUserId, identity) {
@@ -569,23 +519,23 @@ function firstChat(channelUserId, identity) {
const channelId = Number(channelUserId) || 0;
const channelId = Number(channelUserId) || 0;
const key = String(identity || '');
const key = String(identity || '');
if (!channelId || !key) return Promise.resolve(false);
if (!channelId || !key) return Promise.resolve(false);
- return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
+ return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
}).then((v) => {
}).then((v) => {
if (v === true || v === false) return v;
if (v === true || v === false) return v;
- return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
+ return false; // Chat unreachable: not first (Live keeps no stream_first_chats)
});
});
}
}
-/** The synchronous form (the AI persona prompt): the cached Chat answer, else Live's own table when Live runs chat. */
+/** The synchronous form (the AI persona prompt): the cached Chat answer, else false. */
function firstChatPeek(channelUserId, identity) {
function firstChatPeek(channelUserId, identity) {
const channelId = Number(channelUserId) || 0;
const channelId = Number(channelUserId) || 0;
const key = String(identity || '');
const key = String(identity || '');
if (!channelId || !key) return false;
if (!channelId || !key) return false;
- const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
+ const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
});
});
if (v === true || v === false) return v;
if (v === true || v === false) return v;
- return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
+ return false; // Chat unreachable: not first (Live keeps no stream_first_chats)
}
}
// ── Moderation queues ─────────────────────────────────────────────────────────
// ── Moderation queues ─────────────────────────────────────────────────────────
@@ -601,7 +551,7 @@ function pendingIp(channelId, { limit = 50 } = {}) {
@@ -601,7 +551,7 @@ function pendingIp(channelId, { limit = 50 } = {}) {
const out = await client.readPendingIp({ channel_id: id, limit });
const out = await client.readPendingIp({ channel_id: id, limit });
if (!out || !Array.isArray(out.pending_ip)) throw unavailable('pending IP queue');
if (!out || !Array.isArray(out.pending_ip)) throw unavailable('pending IP queue');
return out.pending_ip;
return out.pending_ip;
- }, () => local().getPendingIpMessages(id, { limit }) || [], { ttl: 0, strict: true });
+ }, () => [], { ttl: 0, strict: true });
}
}
/** The hidden relay users of a channel (plus the site-wide rows): [{ … }]. */
/** The hidden relay users of a channel (plus the site-wide rows): [{ … }]. */
@@ -612,7 +562,7 @@ function relayUsers(channelId, { limit = 100 } = {}) {
@@ -612,7 +562,7 @@ function relayUsers(channelId, { limit = 100 } = {}) {
const out = await client.readRelayUsers({ channel_id: id || undefined, limit });
const out = await client.readRelayUsers({ channel_id: id || undefined, limit });
if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');
if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');
return out.relay_users;
return out.relay_users;
- }, () => local().getHiddenRelayUsers(id, { limit }) || [], { ttl: 0, strict: true });
+ }, () => [], { ttl: 0, strict: true });
}
}
/** One hidden relay user by id: { id, channel_id, … } | null. */
/** One hidden relay user by id: { id, channel_id, … } | null. */
@@ -623,26 +573,20 @@ function relayUser(id) {
@@ -623,26 +573,20 @@ function relayUser(id) {
const out = await client.readRelayUser(rid);
const out = await client.readRelayUser(rid);
if (!out) throw unavailable('hidden relay user');
if (!out) throw unavailable('hidden relay user');
return out.relay_user || null;
return out.relay_user || null;
- }, () => {
- const r = local().get('SELECT id, channel_id FROM hidden_relay_users WHERE id = ?', [rid]);
- return r || null;
- }, { ttl: 0, strict: true });
+ }, () => null, { ttl: 0, strict: true });
}
}
/**
/**
* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay
* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay
* path can carry a message per second and must not wait on an HTTP call.
* path can carry a message per second and must not wait on an HTTP call.
*
*
- * Chat owns hidden_relay_users, so in chat mode Chat's list is the only truth: the fresh answer
- * when there is one, else the last good list while a refresh runs in the background. A cold cache
- * (Chat has never answered) fails OPEN — never drop a relayed line on a Chat outage — and Live's
- * own copy (frozen since the mirror's retirement) is never consulted. A Live-route hide/unhide
- * invalidates the cached list, so the next check re-asks Chat. Live's own table answers only when
- * Live is not in chat mode.
+ * Chat owns hidden_relay_users, so Chat's list is the only truth: the fresh answer when there is
+ * one, else the last good list while a refresh runs in the background. A cold cache (Chat has never
+ * answered) fails OPEN — never drop a relayed line on a Chat outage. A Live-route hide/unhide
+ * invalidates the cached list, so the next check re-asks Chat. Outside chat mode: false.
*/
*/
function isRelayUserHidden(channelId, platform, username) {
function isRelayUserHidden(channelId, platform, username) {
- const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };
- if (!remote()) return localHidden();
+ if (!remote()) return false; // Live keeps no hidden_relay_users
const key = `hru:${Number(channelId) || 0}`;
const key = `hru:${Number(channelId) || 0}`;
const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));
const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));
const read = async () => {
const read = async () => {
@@ -658,7 +602,7 @@ function isRelayUserHidden(channelId, platform, username) {
@@ -658,7 +602,7 @@ function isRelayUserHidden(channelId, platform, username) {
return matches(last.value); // the last good list, while the refresh runs
return matches(last.value); // the last good list, while the refresh runs
}
}
load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });
load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });
- return false; // never answered: fail open, never Live's frozen table
+ return false; // never answered: fail open
}
}
/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */
/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */
@@ -669,12 +613,11 @@ function ttsOverride(identityKey) {
@@ -669,12 +613,11 @@ function ttsOverride(identityKey) {
const out = await client.readTtsOverride({ identity_key: k });
const out = await client.readTtsOverride({ identity_key: k });
if (!out) throw unavailable('tts override');
if (!out) throw unavailable('tts override');
return out.tts_override || null;
return out.tts_override || null;
- }, () => local().getTtsVoiceOverride(k), { ttl: 0, strict: true });
+ }, () => null, { ttl: 0, strict: true });
}
}
/**
/**
* The TTS engine's synchronous read: the last good override while Chat refreshes in the background.
* The TTS engine's synchronous read: the last good override while Chat refreshes in the background.
- * In chat mode a cache miss or a Chat failure answers null (the auto voice) — Live's own copy is a
- * frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
+ * A cache miss or a Chat failure answers null (the auto voice) — Live keeps no copy of the table.
*/
*/
function ttsOverridePeek(identityKey) {
function ttsOverridePeek(identityKey) {
const k = String(identityKey || '').trim().toLowerCase();
const k = String(identityKey || '').trim().toLowerCase();
@@ -683,7 +626,7 @@ function ttsOverridePeek(identityKey) {
@@ -683,7 +626,7 @@ function ttsOverridePeek(identityKey) {
const out = await client.readTtsOverride({ identity_key: k });
const out = await client.readTtsOverride({ identity_key: k });
if (!out) throw unavailable('tts override');
if (!out) throw unavailable('tts override');
return out.tts_override || null;
return out.tts_override || null;
- }, () => local().getTtsVoiceOverride(k));
+ }, () => null);
}
}
// ── Channel sounds ────────────────────────────────────────────────────────────
// ── Channel sounds ────────────────────────────────────────────────────────────
@@ -695,10 +638,7 @@ function soundCount(ownerId) {
@@ -695,10 +638,7 @@ function soundCount(ownerId) {
const out = await client.readSounds({ channel_owner_id: id });
const out = await client.readSounds({ channel_owner_id: id });
if (!out || out.count == null) throw unavailable('sound count');
if (!out || out.count == null) throw unavailable('sound count');
return Number(out.count);
return Number(out.count);
- }, () => {
- const r = local().get('SELECT COUNT(*) AS count FROM channel_sounds WHERE channel_owner_id = ?', [id]);
- return Number(r && r.count) || 0;
- });
+ }, () => 0);
}
}
const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');
const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');
@@ -707,21 +647,20 @@ async function remoteSoundByCommand(ownerId, command) {
@@ -707,21 +647,20 @@ async function remoteSoundByCommand(ownerId, command) {
if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answer
if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answer
return out.sound || false; // the sound, or Chat's definitive none
return out.sound || false; // the sound, or Chat's definitive none
}
}
-/** The approved sound a !command plays (or null). Chat owns the row in chat mode; Live's own table answers only when Live runs chat. */
+/** The approved sound a !command plays (or null). Chat owns the row; Live keeps no copy. */
function soundByCommand(ownerId, command) {
function soundByCommand(ownerId, command) {
const id = Number(ownerId) || 0;
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
const cmd = normalizeCommand(command);
if (!id || !cmd) return Promise.resolve(null);
if (!id || !cmd) return Promise.resolve(null);
- const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
- return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn)
- .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable — never Live's frozen row
+ return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => null)
+ .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable
}
}
/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */
/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */
function soundByCommandPeek(ownerId, command) {
function soundByCommandPeek(ownerId, command) {
const id = Number(ownerId) || 0;
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
const cmd = normalizeCommand(command);
if (!id || !cmd) return null;
if (!id || !cmd) return null;
- const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => local().getChannelSoundByCommand(id, cmd) || null);
+ const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => null);
return v && v !== false ? v : null;
return v && v !== false ? v : null;
}
}
@@ -730,9 +669,9 @@ const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is pl
@@ -730,9 +669,9 @@ const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is pl
const DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly
const DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly
/**
/**
* Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's
* Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's
- * block-state read (chat.messages.read); Live's own dm.js table answers only when Live is not in
- * chat mode. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it —
- * blocks are rare).
+ * block-state read (chat.messages.read); outside chat mode there is no local table and the answer
+ * is null. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it — blocks
+ * are rare).
*
*
* `strict`: past the cache and with Chat unreachable the answer is null, never the last good
* `strict`: past the cache and with Chat unreachable the answer is null, never the last good
* value, and the call-invite route fails closed on it — refusing a ring is far better than ringing
* value, and the call-invite route fails closed on it — refusing a ring is far better than ringing
@@ -747,7 +686,7 @@ function dmBlocked(a, b) {
@@ -747,7 +686,7 @@ function dmBlocked(a, b) {
const out = await client.readDmBlockState({ a: x, b: y });
const out = await client.readDmBlockState({ a: x, b: y });
if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');
if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');
return out.blocked;
return out.blocked;
- }, () => safeLocal(() => require('./dm').isBlockedEither(x, y), null), {
+ }, () => null, { // Live keeps no dm_blocks; outside chat mode the gate fails closed
strict: true,
strict: true,
ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),
ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),
}).catch((err) => {
}).catch((err) => {
@@ -766,23 +705,13 @@ function pendingSounds({ channelOwnerId, afterId, limit = 100 } = {}) {
@@ -766,23 +705,13 @@ function pendingSounds({ channelOwnerId, afterId, limit = 100 } = {}) {
const out = await client.readSounds({ pending_asset: true, channel_owner_id: channelOwnerId || undefined, after_id: afterId || undefined, limit });
const out = await client.readSounds({ pending_asset: true, channel_owner_id: channelOwnerId || undefined, after_id: afterId || undefined, limit });
if (!out || !Array.isArray(out.sounds)) throw unavailable('pending sounds');
if (!out || !Array.isArray(out.sounds)) throw unavailable('pending sounds');
return out.sounds;
return out.sounds;
- }, () => {
- const db = local();
- const params = [];
- let sql = 'SELECT * FROM channel_sounds WHERE media_asset_id IS NULL';
- if (channelOwnerId) { sql += ' AND channel_owner_id = ?'; params.push(Number(channelOwnerId)); }
- if (afterId) { sql += ' AND id > ?'; params.push(Number(afterId)); }
- params.push(limit);
- return db.all(`${sql} ORDER BY id LIMIT ?`, params) || [];
- }, { ttl: 0 });
+ }, () => [], { ttl: 0 });
}
}
/** Record where the asset sync put a channel sound (Chat owns the row; a repeat is a no-op). */
/** Record where the asset sync put a channel sound (Chat owns the row; a repeat is a no-op). */
async function recordSoundAsset(id, mediaUrl, mediaAssetId) {
async function recordSoundAsset(id, mediaUrl, mediaAssetId) {
if (!(Number(id) > 0) || !(Number(mediaAssetId) > 0) || !mediaUrl) return null;
if (!(Number(id) > 0) || !(Number(mediaAssetId) > 0) || !mediaUrl) return null;
- if (!remote()) {
- try { local().run('UPDATE channel_sounds SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl, mediaAssetId, id]); return {}; } catch { return null; }
- }
+ if (!remote()) return null; // Live keeps no channel_sounds; nothing to record on
return client.soundAsset({ id: Number(id), media_url: String(mediaUrl), media_asset_id: Number(mediaAssetId) });
return client.soundAsset({ id: Number(id), media_url: String(mediaUrl), media_asset_id: Number(mediaAssetId) });
}
}
@@ -2,33 +2,20 @@
@@ -2,33 +2,20 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Deploy notices in chat — one tidy, rolling message instead of a line per restart.
// Deploy notices in chat — one tidy, rolling message instead of a line per restart.
//
//
-// What went wrong before: every boot inserted a chat row holding the latest three commit subjects
-// (which overlap from one restart to the next) with a relative time baked into the text ("2m ago"
-// forever). Fourteen deploys made fourteen rows repeating each other.
-//
-// Now:
-// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
-// A restart with no new code says nothing.
-// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a
-// deploy notice (nobody has spoken anywhere since) and it is under 3 hours old, it is updated in place.
-// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
-// updated_at }. Clients render times from ISO timestamps, so they are always right.
-// • The live broadcast carries the row id; clients replace the card with that id instead of
-// appending, so reconnects and repeats can never duplicate it.
-// • The deploy is also a durable OpenVibe.Events event, live.release.deployed
-// (server/events/release-events.js), queued in the SAME transaction that records the commits
-// as announced (and, without the Chat service, stores the chat row). OpenVibe.Chat consumes
-// that event and folds the card by head commit (compatibility register C-84, proven on the
-// 2026-09-23 22:14 UTC deploy). With Events publishing off there is no other way to Chat (its
-// ingress has no deploy endpoint), so the commits stay unannounced and the next boot tries again.
+// Only commits that were never announced are announced (site setting `deploy_last_announced`); a
+// restart with no new code says nothing. The deploy is a durable OpenVibe.Events event,
+// live.release.deployed (server/events/release-events.js), queued in the SAME transaction that
+// records the commits as announced. OpenVibe.Chat consumes that event, stores the rolling message
+// (folding consecutive deploys into ONE row by head commit — compatibility register C-84, proven on
+// the 2026-09-23 22:14 UTC deploy) and broadcasts the card. With Events publishing off there is no
+// other way to Chat (its ingress has no deploy endpoint), so the commits stay unannounced and the
+// next boot tries again.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { execFile } = require('child_process');
const { execFile } = require('child_process');
const path = require('path');
const path = require('path');
const REPO_DIR = path.join(__dirname, '..', '..');
const REPO_DIR = path.join(__dirname, '..', '..');
const SETTING = 'deploy_last_announced';
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 MAX_COMMITS = 40;
const git = (args, timeout = 5000) => new Promise((resolve) => {
const git = (args, timeout = 5000) => new Promise((resolve) => {
@@ -56,30 +43,6 @@ async function newCommits(db) {
@@ -56,30 +43,6 @@ async function newCommits(db) {
return { head, previous, commits: parseLog(raw) };
return { head, previous, commits: parseLog(raw) };
}
}
-const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
-
-/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
-function persist(db, commits) {
- const nowIso = new Date().toISOString();
- // Newest across EVERY room: the global feed shows stream and channel messages too, so folding
- // while people chatted in a stream left a card whose time range ran past the messages under it.
- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
- let prev = null;
- if (newest && newest.message_type === 'system' && newest.metadata) {
- try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
- }
- if (prev) {
- const seen = new Set(commits.map(c => c.hash));
- const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
- const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
- db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
- return { id: newest.id, meta };
- }
- const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
- const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
- return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };
-}
-
/**
/**
* Announce this boot's new commits. Safe to call once after boot.
* Announce this boot's new commits. Safe to call once after boot.
* @returns {Promise<{ announced: number, event_id?: string|null }>}
* @returns {Promise<{ announced: number, event_id?: string|null }>}
@@ -103,23 +66,14 @@ async function announce({ db, log = console }) {
@@ -103,23 +66,14 @@ async function announce({ db, log = console }) {
};
};
const inTransaction = (fn) => db.getDb().transaction(fn)();
const inTransaction = (fn) => db.getDb().transaction(fn)();
- // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling
- // message and shows it (its own copy of this module). It learns the commits from the
- // live.release.deployed event only. Chat's ingress has no deploy endpoint: with Events publishing off
- // the commits are not recorded as announced and the next boot tries again (OpenVibe.Chat docs/chat-ingress.md).
- if (require('./chat-delivery').ingress()) {
- if (!outbox) { log.warn('[Deploy notice] Events outbox unavailable; left unannounced for the next boot'); return { announced: 0 }; }
- try { inTransaction(recordDeploy); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); return { announced: commits.length, event_id: null }; }
- if (outbox) outbox.kick();
- return { announced: commits.length, event_id: eventId };
- }
-
- let saved;
- try { saved = inTransaction(() => { const s = persist(db, commits); recordDeploy(); return s; }); }
- catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
- if (outbox) outbox.kick();
-
+ // Live still decides what shipped; OpenVibe.Chat stores the rolling message and shows it (its
+ // own copy of this module). It learns the commits from the live.release.deployed event only.
+ // Chat's ingress has no deploy endpoint: with Events publishing off the commits are not
+ // recorded as announced and the next boot tries again (OpenVibe.Chat docs/chat-ingress.md).
+ if (!outbox) { log.warn('[Deploy notice] Events outbox unavailable; left unannounced for the next boot'); return { announced: 0, event_id: null }; }
+ try { inTransaction(recordDeploy); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); return { announced: 0, event_id: null }; }
+ outbox.kick();
return { announced: commits.length, event_id: eventId };
return { announced: commits.length, event_id: eventId };
}
}
-module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };
+module.exports = { announce, newCommits, parseLog, SETTING };
deleted file mode 100644
deleted file mode 100644
@@ -1,477 +0,0 @@
@@ -1,477 +0,0 @@
-/**
- * OpenVibe.Live — Direct Message System
- *
- * Facebook Messenger-style DMs: 1-on-1 and group conversations,
- * persisted to SQLite, real-time delivery via WebSocket, with
- * read receipts and unread counts.
- *
- * Tables:
- * dm_conversations — conversation metadata
- * dm_participants — who's in each conversation
- * dm_messages — individual messages
- */
-const db = require('../db/database');
-
-// ── Schema & Migrations ──────────────────────────────────────
-
-function ensureTables() {
- const database = db.getDb();
- try {
- database.exec(`
- CREATE TABLE IF NOT EXISTS dm_conversations (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT,
- is_group INTEGER DEFAULT 0,
- created_by INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS dm_participants (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- conversation_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',
- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(conversation_id, user_id),
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS dm_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- conversation_id INTEGER NOT NULL,
- sender_id INTEGER NOT NULL,
- message TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
- FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- CREATE INDEX IF NOT EXISTS idx_dm_participants_conv ON dm_participants(conversation_id);
- CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
- CREATE INDEX IF NOT EXISTS idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
- CREATE INDEX IF NOT EXISTS idx_dm_messages_sender ON dm_messages(sender_id);
-
- CREATE TABLE IF NOT EXISTS dm_blocks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- blocker_id INTEGER NOT NULL,
- blocked_id INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(blocker_id, blocked_id),
- FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocker ON dm_blocks(blocker_id);
- CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocked ON dm_blocks(blocked_id);
- `);
- console.log('[DM] Tables ready');
- } catch (e) {
- console.warn('[DM] Table migration:', e.message);
- }
-}
-
-// ── Conversation helpers ─────────────────────────────────────
-
-/**
- * Find an existing 1-on-1 conversation between two users.
- * Returns conversation row or null.
- */
-function findDirectConversation(userIdA, userIdB) {
- return db.get(`
- SELECT c.* FROM dm_conversations c
- JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?
- JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?
- WHERE c.is_group = 0
- `, [userIdA, userIdB]) || null;
-}
-
-/**
- * Create a new conversation. Returns the new conversation id.
- * participantIds should include the creator.
- */
-function createConversation(createdBy, participantIds, name = null) {
- const isGroup = participantIds.length > 2 ? 1 : 0;
- const result = db.run(
- `INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,
- [isGroup ? (name || null) : null, isGroup, createdBy]
- );
- const convId = result.lastInsertRowid;
- const insert = db.getDb().prepare(
- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id) VALUES (?, ?)`
- );
- for (const uid of participantIds) {
- insert.run(convId, uid);
- }
- return convId;
-}
-
-/**
- * Get or create a 1-on-1 conversation between two users.
- */
-function getOrCreateDirect(userIdA, userIdB) {
- const existing = findDirectConversation(userIdA, userIdB);
- if (existing) return existing.id;
- return createConversation(userIdA, [userIdA, userIdB]);
-}
-
-/**
- * Add a participant to an existing group conversation.
- */
-function addParticipant(conversationId, userId) {
- db.run(
- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id) VALUES (?, ?)`,
- [conversationId, userId]
- );
- // Mark as group if > 2 participants
- const count = db.get(
- `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
- [conversationId]
- )?.c || 0;
- if (count > 2) {
- db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);
- }
-}
-
-/**
- * Remove a participant from a group conversation.
- */
-function removeParticipant(conversationId, userId) {
- db.run(
- `DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
- [conversationId, userId]
- );
-}
-
-/**
- * Check if a user is a participant in a conversation.
- */
-function isParticipant(conversationId, userId) {
- return !!db.get(
- `SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
- [conversationId, userId]
- );
-}
-
-/**
- * Rename a group conversation.
- */
-function renameConversation(conversationId, name) {
- db.run(`UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, conversationId]);
-}
-
-/**
- * Get all conversations for a user with last message preview and unread count.
- */
-function getConversations(userId) {
- return db.all(`
- SELECT
- c.id,
- c.name,
- c.is_group,
- c.updated_at,
- (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message,
- (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id,
- (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at,
- (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count
- FROM dm_conversations c
- JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ?
- ORDER BY
- COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC
- `, [userId, userId]);
-}
-
-/**
- * Get participants of a conversation (with user profile info).
- */
-function getParticipants(conversationId) {
- return db.all(`
- SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color
- FROM dm_participants p
- JOIN users u ON u.id = p.user_id
- WHERE p.conversation_id = ?
- `, [conversationId]);
-}
-
-/**
- * Get a single conversation by id (with participant info for the requesting user).
- */
-function getConversation(conversationId) {
- return db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;
-}
-
-// ── Message helpers ──────────────────────────────────────────
-
-/**
- * Send a message in a conversation. Returns the message row.
- */
-function sendMessage(conversationId, senderId, text) {
- if (!text || !text.trim()) return null;
- const trimmed = text.trim().slice(0, 2000); // max 2000 chars
- const result = db.run(
- `INSERT INTO dm_messages (conversation_id, sender_id, message) VALUES (?, ?, ?)`,
- [conversationId, senderId, trimmed]
- );
- // Touch conversation updated_at
- db.run(`UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]);
- // Auto-mark read for sender
- markRead(conversationId, senderId);
- return db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
-}
-
-/**
- * Get messages in a conversation (paginated, newest first).
- */
-function getMessages(conversationId, limit = 50, before = null, after = null) {
- if (after) {
- // Fetch messages newer than `after` id (for live polling)
- return db.all(`
- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
- FROM dm_messages m
- JOIN users u ON u.id = m.sender_id
- WHERE m.conversation_id = ? AND m.id > ?
- ORDER BY m.created_at ASC
- LIMIT ?
- `, [conversationId, after, limit]);
- }
- if (before) {
- return db.all(`
- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
- FROM dm_messages m
- JOIN users u ON u.id = m.sender_id
- WHERE m.conversation_id = ? AND m.id < ?
- ORDER BY m.created_at DESC
- LIMIT ?
- `, [conversationId, before, limit]);
- }
- return db.all(`
- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
- FROM dm_messages m
- JOIN users u ON u.id = m.sender_id
- WHERE m.conversation_id = ?
- ORDER BY m.created_at DESC
- LIMIT ?
- `, [conversationId, limit]);
-}
-
-/**
- * Mark a conversation as read for a user (set last_read_at to now).
- */
-function markRead(conversationId, userId) {
- db.run(
- `UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?`,
- [conversationId, userId]
- );
-}
-
-/**
- * Get total unread message count across all conversations for a user.
- */
-function getTotalUnread(userId) {
- const row = db.get(`
- SELECT COALESCE(SUM(unread), 0) as total FROM (
- SELECT COUNT(*) as unread
- FROM dm_messages m
- JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?
- WHERE m.created_at > p.last_read_at AND m.sender_id != ?
- )
- `, [userId, userId]);
- return row?.total || 0;
-}
-
-/**
- * Search users by username/display_name for the "new message" user picker.
- * Excludes the requesting user.
- */
-function searchUsers(query, excludeUserId, limit = 10) {
- if (!query || query.length < 2) return [];
- return db.all(`
- SELECT id, username, display_name, avatar_url, profile_color
- FROM users
- WHERE id != ? AND is_banned = 0
- AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?)
- AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)
- AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)
- LIMIT ?
- `, [excludeUserId, excludeUserId, excludeUserId, `%${query}%`, `%${query}%`, limit]);
-}
-
-// ── Block helpers ────────────────────────────────────────────
-
-/**
- * Check if either user has blocked the other (bidirectional).
- */
-function isBlockedEither(userIdA, userIdB) {
- return !!db.get(
- `SELECT 1 FROM dm_blocks
- WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
- [userIdA, userIdB, userIdB, userIdA]
- );
-}
-
-/**
- * Check if blocker has blocked blocked (one-directional).
- */
-function hasBlocked(blockerId, blockedId) {
- return !!db.get(
- `SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
- [blockerId, blockedId]
- );
-}
-
-/**
- * Block a user.
- */
-function blockUser(blockerId, blockedId) {
- db.run(
- `INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id) VALUES (?, ?)`,
- [blockerId, blockedId]
- );
-}
-
-/**
- * Unblock a user.
- */
-function unblockUser(blockerId, blockedId) {
- db.run(
- `DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
- [blockerId, blockedId]
- );
-}
-
-/**
- * Get all users blocked by blockerId.
- */
-function getBlockedUsers(blockerId) {
- return db.all(`
- SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at
- FROM dm_blocks b
- JOIN users u ON u.id = b.blocked_id
- WHERE b.blocker_id = ?
- ORDER BY b.created_at DESC
- `, [blockerId]);
-}
-
-/**
- * Delete a message (only the sender can delete their own message).
- */
-function deleteMessage(messageId, userId) {
- const msg = db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);
- if (!msg || msg.sender_id !== userId) return false;
- db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);
- return true;
-}
-
-/**
- * Get the number of participants in a conversation.
- */
-function getParticipantCount(conversationId) {
- const row = db.get(
- `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
- [conversationId]
- );
- return row?.c || 0;
-}
-
-// ── Anti-spam helpers ────────────────────────────────────────
-
-/**
- * Detect spam patterns in DM message content.
- * Returns { isSpam: boolean, reason: string|null }
- */
-function checkMessageSpam(text) {
- if (!text) return { isSpam: false, reason: null };
- const trimmed = text.trim();
-
- // Excessive caps (>70% caps in messages over 10 chars)
- if (trimmed.length > 10) {
- const alphaChars = trimmed.replace(/[^a-zA-Z]/g, '');
- if (alphaChars.length > 5) {
- const capsRatio = (alphaChars.replace(/[^A-Z]/g, '').length) / alphaChars.length;
- if (capsRatio > 0.7) return { isSpam: true, reason: 'Excessive caps' };
- }
- }
-
- // Repeated characters (e.g., "aaaaaaa" or "!!!!!!")
- if (/(.)\1{9,}/i.test(trimmed)) {
- return { isSpam: true, reason: 'Repeated characters' };
- }
-
- // Repeated words (same word 5+ times)
- const words = trimmed.toLowerCase().split(/\s+/);
- if (words.length >= 5) {
- const freq = {};
- for (const w of words) freq[w] = (freq[w] || 0) + 1;
- for (const w of Object.keys(freq)) {
- if (freq[w] >= 5 && freq[w] / words.length > 0.6) {
- return { isSpam: true, reason: 'Repetitive content' };
- }
- }
- }
-
- // Common spam patterns (URLs in bulk, typical scam patterns)
- const urlCount = (trimmed.match(/https?:\/\//gi) || []).length;
- if (urlCount >= 3) return { isSpam: true, reason: 'Too many URLs' };
-
- return { isSpam: false, reason: null };
-}
-
-/**
- * Check if this message is a duplicate of the user's recent messages.
- * Returns true if the exact same text was sent within the last N seconds.
- */
-function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {
- const row = db.get(`
- SELECT 1 FROM dm_messages
- WHERE conversation_id = ? AND sender_id = ? AND message = ?
- AND created_at > datetime('now', '-' || ? || ' seconds')
- LIMIT 1
- `, [conversationId, senderId, text.trim().slice(0, 2000), windowSeconds]);
- return !!row;
-}
-
-/**
- * Check if a user account is too new to send DMs (minimum account age).
- * Returns { tooNew: boolean, minutesRemaining: number }
- */
-function isAccountTooNew(userId, minMinutes = 5) {
- const user = db.get(`SELECT created_at FROM users WHERE id = ?`, [userId]);
- if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };
- const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');
- const ageMs = Date.now() - created.getTime();
- const ageMinutes = ageMs / 60000;
- if (ageMinutes < minMinutes) {
- return { tooNew: true, minutesRemaining: Math.ceil(minMinutes - ageMinutes) };
- }
- return { tooNew: false, minutesRemaining: 0 };
-}
-
-module.exports = {
- ensureTables,
- findDirectConversation,
- createConversation,
- getOrCreateDirect,
- addParticipant,
- removeParticipant,
- isParticipant,
- renameConversation,
- getConversations,
- getParticipants,
- getConversation,
- sendMessage,
- getMessages,
- markRead,
- getTotalUnread,
- searchUsers,
- isBlockedEither,
- hasBlocked,
- blockUser,
- unblockUser,
- getBlockedUsers,
- deleteMessage,
- getParticipantCount,
- checkMessageSpam,
- isDuplicateMessage,
- isAccountTooNew,
-};
@@ -623,8 +623,7 @@ effectsRouter.post('/notify/dm-read', (req, res) => {
@@ -623,8 +623,7 @@ effectsRouter.post('/notify/dm-read', (req, res) => {
});
});
// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the
// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the
-// rows since T3). Chat sends the Media asset id it holds on `asset_id`; Live's own channel_sounds
-// copy is a frozen pre-retirement snapshot and is never read for it.
+// rows since T3, and Live keeps no copy). Chat sends the Media asset id it holds on `asset_id`.
effectsRouter.post('/asset-sync', (req, res) => {
effectsRouter.post('/asset-sync', (req, res) => {
try {
try {
const sync = require('../media-proxy/asset-sync');
const sync = require('../media-proxy/asset-sync');
@@ -413,81 +413,6 @@ function initDb() {
@@ -413,81 +413,6 @@ function initDb() {
database.exec(`CREATE INDEX IF NOT EXISTS idx_linked_user ON linked_accounts(user_id)`);
database.exec(`CREATE INDEX IF NOT EXISTS idx_linked_user ON linked_accounts(user_id)`);
} catch (e) { console.warn('[DB] linked_accounts migration:', e.message); }
} catch (e) { console.warn('[DB] linked_accounts migration:', e.message); }
- // Migrate: make chat_messages.stream_id nullable (was NOT NULL, broke global chat saves)
- try {
- const cmCols = database.prepare("PRAGMA table_info('chat_messages')").all();
- const streamIdCol = cmCols.find(c => c.name === 'stream_id');
- if (streamIdCol && streamIdCol.notnull === 1) {
- database.exec(`
- CREATE TABLE chat_messages_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- stream_id INTEGER,
- user_id INTEGER,
- anon_id TEXT,
- username TEXT,
- message TEXT NOT NULL,
- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts')),
- is_global INTEGER DEFAULT 0,
- is_deleted INTEGER DEFAULT 0,
- is_filtered INTEGER DEFAULT 0,
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
- );
- INSERT INTO chat_messages_new SELECT * FROM chat_messages;
- DROP TABLE chat_messages;
- ALTER TABLE chat_messages_new RENAME TO chat_messages;
- CREATE INDEX IF NOT EXISTS idx_chat_stream ON chat_messages(stream_id);
- CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(user_id);
- CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);
- `);
- console.log('[DB] Migrated chat_messages.stream_id to nullable');
- }
- } catch (e) { console.warn('[DB] chat_messages migration:', e.message); }
-
- // Migrate: add reply_to_id column to chat_messages for threaded replies
- try {
- const cmCols2 = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => c.name);
- if (!cmCols2.includes('reply_to_id')) {
- database.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL`);
- console.log('[DB] Added reply_to_id column to chat_messages');
- }
- } catch (e) { console.warn('[DB] chat_messages reply_to_id migration:', e.message); }
-
- // Migrate: widen the message_type CHECK so sound announces ('channel-sound',
- // 'soundboard') persist to history. SQLite can't alter a CHECK — rebuild the
- // table from its CURRENT definition (columns have grown over time).
- try {
- const tbl = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chat_messages'").get();
- const REQUIRED_TYPES = ['channel-sound', 'soundboard', 'clip'];
- if (tbl && /message_type IN \([^)]*\)/.test(tbl.sql) && REQUIRED_TYPES.some(t => !tbl.sql.includes(`'${t}'`))) {
- const newSql = tbl.sql
- .replace(/CREATE TABLE ["']?chat_messages["']?/, 'CREATE TABLE chat_messages_new')
- .replace(/message_type IN \([^)]*\)/,
- "message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')");
- const cols = database.prepare("PRAGMA table_info('chat_messages')").all().map(c => `"${c.name}"`).join(', ');
- const indexes = database.prepare(
- "SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL"
- ).all().map(r => r.sql);
- database.pragma('foreign_keys = OFF');
- try {
- database.exec('BEGIN');
- database.exec(newSql);
- database.exec(`INSERT INTO chat_messages_new (${cols}) SELECT ${cols} FROM chat_messages`);
- database.exec('DROP TABLE chat_messages');
- database.exec('ALTER TABLE chat_messages_new RENAME TO chat_messages');
- for (const sql of indexes) { try { database.exec(sql); } catch { /* index name may survive */ } }
- database.exec('COMMIT');
- console.log('[DB] Widened chat_messages.message_type CHECK for sound announces');
- } catch (e2) {
- try { database.exec('ROLLBACK'); } catch { /* no open tx */ }
- throw e2;
- } finally {
- database.pragma('foreign_keys = ON');
- }
- }
- } catch (e) { console.warn('[DB] chat_messages message_type migration:', e.message); }
-
// Migrate: add default_vod_visibility / default_clip_visibility to channels
// Migrate: add default_vod_visibility / default_clip_visibility to channels
try {
try {
const chanCols = database.prepare("PRAGMA table_info('channels')").all().map(c => c.name);
const chanCols = database.prepare("PRAGMA table_info('channels')").all().map(c => c.name);
@@ -1336,18 +1261,6 @@ function initDb() {
@@ -1336,18 +1261,6 @@ function initDb() {
)`);
)`);
database.exec(`CREATE INDEX IF NOT EXISTS idx_egg_solves_date ON easter_egg_solves(egg_date)`);
database.exec(`CREATE INDEX IF NOT EXISTS idx_egg_solves_date ON easter_egg_solves(egg_date)`);
- // Admin-set per-user TTS voice overrides (keyed by the same identity key used at
- // synthesis: "user:<username>" / "anon:<id>"). Overrides the auto-assigned voice.
- database.exec(`CREATE TABLE IF NOT EXISTS tts_voice_overrides (
- identity_key TEXT PRIMARY KEY,
- voice TEXT,
- pitch INTEGER,
- speed INTEGER,
- gap INTEGER DEFAULT 0,
- set_by INTEGER,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )`);
-
// `pastes` is created several hundred lines below this point, so on a brand-new database
// `pastes` is created several hundred lines below this point, so on a brand-new database
// this PRAGMA throws — and because it shares a try block with the migrations that follow,
// this PRAGMA throws — and because it shares a try block with the migrations that follow,
// it took them down with it: streams.ai_overview, vods.ai_overview, clips.ai_overview and
// it took them down with it: streams.ai_overview, vods.ai_overview, clips.ai_overview and
@@ -1448,37 +1361,6 @@ function initDb() {
@@ -1448,37 +1361,6 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] short-overview migration:', e.message); }
} catch (e) { console.warn('[DB] short-overview migration:', e.message); }
- // Chat-relay users (external Twitch/Kick/YouTube handles): record their first
- // message as a "join date" + last-seen + message count, for the context menu.
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS relay_users (
- platform TEXT NOT NULL,
- username TEXT NOT NULL,
- display_name TEXT,
- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- message_count INTEGER DEFAULT 0,
- PRIMARY KEY (platform, username)
- )`);
- // One-time backfill: earlier relay sources (notably RobotStreamer) didn't record
- // relay_users, so their existing chatters had no row to hang chat-logs/AI insight
- // off. Seed relay_users from the historical "[Label] name" relay messages. Idempotent
- // (INSERT OR IGNORE keyed on platform+username), guarded so it runs at most once.
- const _relayDone = database.prepare("SELECT value FROM site_settings WHERE key='relay_users_backfilled'").get();
- if (!_relayDone) {
- database.exec(`INSERT OR IGNORE INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)
- SELECT source_platform,
- LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2))),
- TRIM(SUBSTR(username, INSTR(username, '] ') + 2)),
- MIN(timestamp), MAX(timestamp), COUNT(*)
- FROM chat_messages
- WHERE user_id IS NULL AND source_platform IS NOT NULL
- AND username LIKE '[%] %' AND INSTR(username, '] ') > 0
- GROUP BY source_platform, LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2)))`);
- database.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('relay_users_backfilled','1','string')").run();
- }
- } catch (e) { console.warn('[DB] relay_users migration:', e.message); }
-
// Timestamped transcript segments (JSON) — contextual data for the AI system +
// Timestamped transcript segments (JSON) — contextual data for the AI system +
// clickable timestamps in the VOD/clip transcript UI.
// clickable timestamps in the VOD/clip transcript UI.
try {
try {
@@ -1503,14 +1385,6 @@ function initDb() {
@@ -1503,14 +1385,6 @@ function initDb() {
if (!cols.includes('sort_order')) database.exec('ALTER TABLE donation_goals ADD COLUMN sort_order INTEGER DEFAULT 0');
if (!cols.includes('sort_order')) database.exec('ALTER TABLE donation_goals ADD COLUMN sort_order INTEGER DEFAULT 0');
} catch (e) { console.warn('[DB] donation_goals media migration:', e.message); }
} catch (e) { console.warn('[DB] donation_goals media migration:', e.message); }
- // chat_messages.metadata — JSON sidecar for rich/animated events (donations + goal
- // reached) so they persist in history WITH their structured payload (image, amount,
- // goal title, animation kind) while `message` stays human-readable.
- try {
- const cols = database.prepare('PRAGMA table_info(chat_messages)').all().map(c => c.name);
- if (!cols.includes('metadata')) database.exec('ALTER TABLE chat_messages ADD COLUMN metadata TEXT');
- } catch (e) { console.warn('[DB] chat metadata migration:', e.message); }
-
// Migrate: extend the subscriptions table for real recurring billing.
// Migrate: extend the subscriptions table for real recurring billing.
try {
try {
const cols = database.pragma('table_info(subscriptions)').map(c => c.name);
const cols = database.pragma('table_info(subscriptions)').map(c => c.name);
@@ -1567,67 +1441,6 @@ function initDb() {
@@ -1567,67 +1441,6 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] Role migration:', e.message); }
} catch (e) { console.warn('[DB] Role migration:', e.message); }
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS channel_sounds (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_owner_id INTEGER NOT NULL,
- command TEXT NOT NULL,
- url TEXT NOT NULL,
- mime TEXT DEFAULT 'audio/mpeg',
- duration_seconds REAL DEFAULT 0,
- created_by INTEGER,
- created_by_name TEXT DEFAULT '',
- is_approved INTEGER DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE
- )`);
- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id)');
- } catch (e) { console.warn('[DB] channel_sounds migration:', e.message); }
-
- // Migrate: allow MULTIPLE sounds per command (random pick on playback).
- // Older DBs created channel_sounds with UNIQUE(channel_owner_id, command);
- // rebuild the table without it. Idempotent — only fires while the constraint exists.
- try {
- const row = database.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_sounds'").get();
- if (row && /UNIQUE\s*\(\s*channel_owner_id\s*,\s*command\s*\)/i.test(row.sql)) {
- database.exec('PRAGMA foreign_keys=OFF');
- const tx = database.transaction(() => {
- database.exec(`CREATE TABLE channel_sounds_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_owner_id INTEGER NOT NULL,
- command TEXT NOT NULL,
- url TEXT NOT NULL,
- mime TEXT DEFAULT 'audio/mpeg',
- duration_seconds REAL DEFAULT 0,
- created_by INTEGER,
- created_by_name TEXT DEFAULT '',
- is_approved INTEGER DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE
- )`);
- database.exec(`INSERT INTO channel_sounds_new
- (id, channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, is_approved, created_at)
- SELECT id, channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, is_approved, created_at
- FROM channel_sounds`);
- database.exec('DROP TABLE channel_sounds');
- database.exec('ALTER TABLE channel_sounds_new RENAME TO channel_sounds');
- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id)');
- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)');
- });
- tx();
- database.exec('PRAGMA foreign_keys=ON');
- console.log('[DB] Rebuilt channel_sounds without UNIQUE(command) — multiple sounds per command enabled');
- } else {
- database.exec('CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)');
- }
- } catch (e) { console.warn('[DB] channel_sounds multi-sound migration:', e.message); }
-
- // Migrate: optional emote attached to a sound command (shows the emote + "!" in chat).
- try {
- const cols = database.prepare('PRAGMA table_info(channel_sounds)').all().map(c => c.name);
- if (!cols.includes('emote_code')) database.exec("ALTER TABLE channel_sounds ADD COLUMN emote_code TEXT DEFAULT ''");
- } catch (e) { console.warn('[DB] channel_sounds emote_code migration:', e.message); }
-
// Migrate: AI chatbot ("fake viewers") config, one row per streamer (user)
// Migrate: AI chatbot ("fake viewers") config, one row per streamer (user)
try {
try {
database.exec(`CREATE TABLE IF NOT EXISTS ai_chatbot_configs (
database.exec(`CREATE TABLE IF NOT EXISTS ai_chatbot_configs (
@@ -1747,25 +1560,6 @@ function initDb() {
@@ -1747,25 +1560,6 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] channel_ai_config migration:', e.message); }
} catch (e) { console.warn('[DB] channel_ai_config migration:', e.message); }
- // Migrate: create moderation_actions table for audit logging
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS moderation_actions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- scope_type TEXT NOT NULL DEFAULT 'site',
- scope_id INTEGER,
- actor_user_id INTEGER,
- target_user_id INTEGER,
- action_type TEXT NOT NULL,
- details TEXT DEFAULT '{}',
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
- FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
- )`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created_at DESC)`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id)`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id)`);
- } catch (e) { console.warn('[DB] moderation_actions migration:', e.message); }
-
// Migrate: create pastes table if missing
// Migrate: create pastes table if missing
try {
try {
database.exec(`CREATE TABLE IF NOT EXISTS pastes (
database.exec(`CREATE TABLE IF NOT EXISTS pastes (
@@ -1867,17 +1661,6 @@ function initDb() {
@@ -1867,17 +1661,6 @@ function initDb() {
for (const [k, v, d, t] of commentSettings) seedComment.run(k, v, d, t);
for (const [k, v, d, t] of commentSettings) seedComment.run(k, v, d, t);
} catch (e) { console.warn('[DB] paste_comments migration:', e.message); }
} catch (e) { console.warn('[DB] paste_comments migration:', e.message); }
- // Migrate: stream_first_chats — tracks first-time chatters per streamer (for welcome messages)
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS stream_first_chats (
- chatter_key TEXT NOT NULL,
- channel_user_id INTEGER NOT NULL,
- first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (chatter_key, channel_user_id)
- )`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_sfc_channel ON stream_first_chats(channel_user_id)`);
- } catch (e) { console.warn('[DB] stream_first_chats migration:', e.message); }
-
// Migrate: ip_log — tracks IP addresses used by users and anons
// Migrate: ip_log — tracks IP addresses used by users and anons
try {
try {
database.exec(`CREATE TABLE IF NOT EXISTS ip_log (
database.exec(`CREATE TABLE IF NOT EXISTS ip_log (
@@ -1919,94 +1702,6 @@ function initDb() {
@@ -1919,94 +1702,6 @@ function initDb() {
database.exec(`CREATE INDEX IF NOT EXISTS idx_approved_ips_ip ON approved_ips(ip_address)`);
database.exec(`CREATE INDEX IF NOT EXISTS idx_approved_ips_ip ON approved_ips(ip_address)`);
} catch (e) { console.warn('[DB] approved_ips migration:', e.message); }
} catch (e) { console.warn('[DB] approved_ips migration:', e.message); }
- // Migrate: pending_ip_messages — messages held for IP approval when anti-VPN mode is on
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS pending_ip_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_id INTEGER NOT NULL,
- stream_id INTEGER,
- ip_address TEXT NOT NULL,
- user_id INTEGER,
- anon_id TEXT,
- username TEXT,
- message TEXT NOT NULL,
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),
- reviewed_by INTEGER,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
- FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL
- )`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_pending_ip_channel ON pending_ip_messages(channel_id, status)`);
- } catch (e) { console.warn('[DB] pending_ip_messages migration:', e.message); }
-
- // Migrate: hidden_relay_users — relayed external users banned/hidden by streamer or admin
- try {
- database.exec(`CREATE TABLE IF NOT EXISTS hidden_relay_users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_id INTEGER,
- platform TEXT NOT NULL,
- external_username TEXT NOT NULL,
- action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),
- reason TEXT,
- created_by INTEGER,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
- )`);
- database.exec(`CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)`);
- } catch (e) { console.warn('[DB] hidden_relay_users migration:', e.message); }
-
- // Migrate: add deleted_by and deleted_at to chat_messages for soft-delete attribution
- try {
- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
- if (!cols.includes('deleted_by')) {
- database.exec('ALTER TABLE chat_messages ADD COLUMN deleted_by INTEGER');
- }
- if (!cols.includes('deleted_at')) {
- database.exec("ALTER TABLE chat_messages ADD COLUMN deleted_at DATETIME");
- }
- } catch (e) { console.warn('[DB] chat_messages delete attribution migration:', e.message); }
-
- // Migrate: add source_platform column to chat_messages for filtering relayed messages
- try {
- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
- if (!cols.includes('source_platform')) {
- database.exec("ALTER TABLE chat_messages ADD COLUMN source_platform TEXT");
- }
- } catch (e) { console.warn('[DB] chat_messages source_platform migration:', e.message); }
-
- // Migrate: add auto_delete_at to chat_messages for viewer-controlled history cleanup
- try {
- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
- if (!cols.includes('auto_delete_at')) {
- database.exec("ALTER TABLE chat_messages ADD COLUMN auto_delete_at DATETIME");
- }
- } catch (e) { console.warn('[DB] chat_messages auto_delete_at migration:', e.message); }
-
- // Migrate: add channel_user_id to chat_messages — the broadcaster's user id, so
- // a streamer's chat is one persistent room across live slots AND offline periods
- // (history no longer depends on the live-session stream row surviving). Backfill
- // existing rows from their stream's owner.
- try {
- const cols = database.pragma('table_info(chat_messages)').map(c => c.name);
- if (!cols.includes('channel_user_id')) {
- database.exec("ALTER TABLE chat_messages ADD COLUMN channel_user_id INTEGER");
- database.exec("UPDATE chat_messages SET channel_user_id = (SELECT s.user_id FROM streams s WHERE s.id = chat_messages.stream_id) WHERE stream_id IS NOT NULL AND channel_user_id IS NULL");
- console.log('[DB] Added channel_user_id to chat_messages + backfilled');
- }
- } catch (e) { console.warn('[DB] chat_messages channel_user_id migration:', e.message); }
-
- // Performance indexes — runs EVERY boot (CREATE INDEX IF NOT EXISTS is idempotent), so
- // it also fixes the previously-broken chat index (it referenced a non-existent
- // `created_at` column, so it never got created) and covers hot listing queries whose
- // WHERE + ORDER BY (created_at / view_count) previously did full scans + filesorts.
- try {
- // Chat history: filter by channel_user_id / stream_id, ORDER BY timestamp DESC.
- database.exec('CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)');
- database.exec('CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp)');
- // (The VOD/clip listing indexes went with the listings: VODs and clips are listed by OpenVibe.Media.)
- } catch (e) { console.warn('[DB] performance index migration:', e.message); }
-
// Second pass of hot-path indexes, found by tracing what the home page and the always-on
// Second pass of hot-path indexes, found by tracing what the home page and the always-on
// middleware actually execute. Each one replaces a recurring full table scan. They are created
// middleware actually execute. Each one replaces a recurring full table scan. They are created
// individually so a table that does not exist in some deployment cannot stop the rest.
// individually so a table that does not exist in some deployment cannot stop the rest.
@@ -2016,8 +1711,6 @@ function initDb() {
@@ -2016,8 +1711,6 @@ function initDb() {
'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',
'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',
// Looked up once per live stream on /api/streams, which every open home tab polls.
// Looked up once per live stream on /api/streams, which every open home tab polls.
'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',
'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',
- // The chat auto-delete sweep runs every 30s and scanned + sorted the largest table.
- 'CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at)',
// Home digest, per signed-in load.
// Home digest, per signed-in load.
'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)',
'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)',
'CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',
@@ -2037,10 +1730,6 @@ function initDb() {
@@ -2037,10 +1730,6 @@ function initDb() {
// the whole streams table once per managed stream, per row. Measured on a production-scale
// the whole streams table once per managed stream, per row. Measured on a production-scale
// database: 463ms -> 18ms at limit=20, and 1533ms -> 24ms at limit=100.
// database: 463ms -> 18ms at limit=20, and 1533ms -> 24ms at limit=100.
'CREATE INDEX IF NOT EXISTS idx_streams_managed_ended ON streams(managed_stream_id, ended_at)',
'CREATE INDEX IF NOT EXISTS idx_streams_managed_ended ON streams(managed_stream_id, ended_at)',
- // The hero stat board's heaviest queries count chat_messages over a time window with
- // COALESCE(is_deleted,0)=0, which forced a row fetch for every row in the window even
- // though the timestamp index was being used. These make those counts covering.
- 'CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)',
'CREATE INDEX IF NOT EXISTS idx_ai_viewer_log_created ON ai_viewer_log(created_at)',
'CREATE INDEX IF NOT EXISTS idx_ai_viewer_log_created ON ai_viewer_log(created_at)',
]) {
]) {
try { database.exec(ix); } catch { /* table not present in this deployment */ }
try { database.exec(ix); } catch { /* table not present in this deployment */ }
@@ -3248,49 +2937,6 @@ function getStreamTranscriptSegments(streamId) {
@@ -3248,49 +2937,6 @@ function getStreamTranscriptSegments(streamId) {
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
// countAutoClipsSince() removed — the media subsystem (vods/clips/pastes writes) moved to OpenVibe.Media.
-// Live chat-velocity window: message-count buckets over the last `windowSec`, each with a
-// representative epoch timestamp — used to detect a "everyone reacted" spike and locate WHEN.
-function getLiveChatBuckets(streamId, windowSec = 150, bucketSec = 15) {
- try {
- const rows = all(`
- SELECT CAST(strftime('%s', cm.timestamp) / ? AS INT) AS b,
- COUNT(*) AS n,
- MIN(CAST(strftime('%s', cm.timestamp) AS INT)) AS ts
- FROM chat_messages cm
- WHERE cm.stream_id = ? AND COALESCE(cm.is_deleted, 0) = 0
- AND cm.timestamp >= datetime('now', ?)
- GROUP BY b ORDER BY ts ASC`, [bucketSec, streamId, `-${windowSec} seconds`]) || [];
- return rows.map(r => ({ count: r.n, tsEpoch: r.ts }));
- } catch { return []; }
-}
-
-// Recent chat message texts for a stream (AI context for the live auto-clipper — what people
-// were actually saying/reacting to around a spike).
-function getRecentChatText(streamId, sinceSec = 120, limit = 40) {
- try {
- const rows = all(`SELECT message FROM chat_messages
- WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND message <> ''
- AND timestamp >= datetime('now', ?)
- ORDER BY timestamp DESC LIMIT ?`, [streamId, `-${Math.max(1, sinceSec)} seconds`, Math.max(1, limit)]) || [];
- return rows.map(r => String(r.message)).reverse();
- } catch { return []; }
-}
-
-// Chat-message spikes: the busiest time-buckets of a stream (offset seconds → message count),
-// a proxy for "everyone reacted here". Offsets are relative to the stream's start.
-function getChatSpikeOffsets(streamId, bucketSec = 30, topN = 8) {
- try {
- const rows = all(`
- SELECT CAST((julianday(cm.timestamp) - julianday(s.started_at)) * 86400 / ? AS INT) AS bucket,
- COUNT(*) AS n
- FROM chat_messages cm JOIN streams s ON s.id = cm.stream_id
- WHERE cm.stream_id = ? AND s.started_at IS NOT NULL AND cm.timestamp >= s.started_at
- AND COALESCE(cm.is_deleted, 0) = 0
- GROUP BY bucket HAVING bucket >= 0 ORDER BY n DESC LIMIT ?`, [bucketSec, streamId, topN]) || [];
- return rows.map(r => ({ offset: r.bucket * bucketSec, count: r.n }));
- } catch { return []; }
-}
-
function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
function upsertStreamerOverview(userId, { overview, model = null, sources = null }) {
return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
return run(`INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@@ -3805,12 +3451,9 @@ const HOME_SERIES = {
@@ -3805,12 +3451,9 @@ const HOME_SERIES = {
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
visitors: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
visitors: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
follows: { table: 'follows', ts: 'created_at', agg: 'COUNT(*)' },
follows: { table: 'follows', ts: 'created_at', agg: 'COUNT(*)' },
- // Chat's site-wide per-day message/chatter series: in chat mode getHomeStatSeries answers them
- // from Chat's `site-daily` stats read (chat-reads.homeSeriesPeek); this local SQL stays the
- // dev / rollback / Chat-unreachable answer. `messages` maps to Chat's messages series, `active`
- // to its chatters series.
- messages: { table: 'chat_messages', ts: 'timestamp', agg: 'COUNT(*)', where: 'COALESCE(is_deleted, 0) = 0' },
- active: { table: 'chat_messages', ts: 'timestamp', agg: "COUNT(DISTINCT COALESCE('u' || user_id, 'a' || anon_id, 'r' || source_platform || '|' || username))", where: 'COALESCE(is_deleted, 0) = 0' },
+ // No chat series here: OpenVibe.Chat owns chat_messages. `messages` and `active` are answered by
+ // getHomeStatSeries from Chat's `site-daily` stats read; there is no Live-table fallback any more
+ // (since 2026-10-05 Live keeps no copy of Chat's tables), so outside chat mode they answer null.
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
@@ -3826,11 +3469,12 @@ const HOME_SERIES = {
@@ -3826,11 +3469,12 @@ const HOME_SERIES = {
// No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is dropped
// No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is dropped
// since N+2 (unlike the table itself, which N-1 still prepares against — see schema.sql).
// since N+2 (unlike the table itself, which N-1 still prepares against — see schema.sql).
};
};
-const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
+const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'messages', 'active', 'liveNow', 'viewersNow'];
/**
/**
- * The local (Live-table) daily series for one metric — the dev, rollback and Chat-unreachable
- * answer. getHomeStatSeries routes the two chat metrics through Chat first.
+ * The daily series for one metric from Live's own tables. The two chat metrics (`messages`,
+ * `active`) have no entry here: OpenVibe.Chat owns chat_messages, so getHomeStatSeries answers
+ * them from Chat, and with Chat unreachable they answer null rather than a Live-table number.
*/
*/
function homeSeriesLocal(metric, days = 30) {
function homeSeriesLocal(metric, days = 30) {
const def = HOME_SERIES[metric];
const def = HOME_SERIES[metric];
@@ -3980,9 +3624,8 @@ function _computeHomeStats() {
@@ -3980,9 +3624,8 @@ function _computeHomeStats() {
clips: null,
clips: null,
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
streamers: c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
streamers: c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
- // OpenVibe.Chat owns chat_messages (roadmap T3); the total comes from Chat's read API when
- // it is the authority, from Live's own table when Live runs chat itself. A Chat outage
- // answers the cache, else null — never a 500 for the home page.
+ // OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
+ // A Chat outage answers the cache, else null — never a 500 for the home page.
chatMessages: (() => { try { const s = require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
chatMessages: (() => { try { const s = require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
users: c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
anons: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
@@ -4568,115 +4211,6 @@ function cleanupPowerchatDeliveries(days = 3) {
@@ -4568,115 +4211,6 @@ function cleanupPowerchatDeliveries(days = 3) {
catch { return null; }
catch { return null; }
}
}
-// ── Chat helpers ─────────────────────────────────────────────
-
-function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {
- // channel_user_id = the broadcaster's user id — set for all channel/stream
- // messages so a streamer's chat history survives across sessions AND offline
- // periods (independent of the live-session stream row's lifetime).
- let chanUid = channel_user_id || null;
- if (!chanUid && stream_id) { try { chanUid = getStreamById(stream_id)?.user_id || null; } catch { /* ignore */ } }
- const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
- return run(
- `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr]
- );
-}
-
-function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {
- let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color
- FROM chat_messages cm
- LEFT JOIN users u ON cm.user_id = u.id
- WHERE cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
- const params = [];
-
- if (query) {
- sql += ` AND cm.message LIKE ?`;
- params.push(`%${query}%`);
- }
- if (userId) {
- sql += ` AND cm.user_id = ?`;
- params.push(userId);
- }
- if (anonId) {
- sql += ` AND cm.anon_id = ?`;
- params.push(anonId);
- }
- if (username) {
- sql += ` AND LOWER(u.username) LIKE ?`;
- params.push(`%${username.toLowerCase()}%`);
- }
- if (streamId) {
- sql += ` AND cm.stream_id = ?`;
- params.push(streamId);
- }
-
- const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');
- const total = get(countSql, params)?.c || 0;
-
- sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
- params.push(limit, offset);
-
- return { messages: all(sql, params), total };
-}
-
-function getUserChatHistory(userId, limit = 50, offset = 0) {
- const sql = `SELECT cm.*, s.title as stream_title
- FROM chat_messages cm
- LEFT JOIN streams s ON cm.stream_id = s.id
- WHERE cm.user_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)
- ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
- const messages = all(sql, [userId, limit, offset]);
- const total = get(
- `SELECT COUNT(*) as c FROM chat_messages
- WHERE user_id = ? AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
- [userId]
- )?.c || 0;
- return { messages, total };
-}
-
-// A chatter's recent messages in ONE channel (clone source for that channel's AI viewers).
-// relay = { platform, rawUsername } matches relayed chatters instead of a user id.
-function getChatSamplesInChannel(channelUserId, { userId = null, relay = null, limit = 30 } = {}) {
- let where = `cm.is_deleted = 0 AND cm.message_type != 'system'
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)
- AND COALESCE(cm.channel_user_id, (SELECT s.user_id FROM streams s WHERE s.id = cm.stream_id)) = ?`;
- const params = [channelUserId];
- if (relay) { where += ` AND ${_RELAY_MATCH}`; params.push(..._relayMatchParams(relay.platform, relay.rawUsername)); }
- else { where += ' AND cm.user_id = ?'; params.push(userId); }
- return all(`SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id
- FROM chat_messages cm WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ?`,
- [...params, Math.max(1, Math.min(200, limit))]);
-}
-
-// ── Per-user TTS voice overrides (admin-set) ─────────────────
-function getTtsVoiceOverride(identityKey) {
- try {
- const k = String(identityKey || '').trim().toLowerCase();
- if (!k) return null;
- const r = get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);
- if (!r) return null;
- return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };
- } catch { return null; }
-}
-function setTtsVoiceOverride(identityKey, params, setBy) {
- const k = String(identityKey || '').trim().toLowerCase();
- if (!k) return false;
- run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,
- speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP`,
- [k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);
- return true;
-}
-function deleteTtsVoiceOverride(identityKey) {
- try { run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }
-}
-
// ── Daily easter egg solves ──────────────────────────────────
// ── Daily easter egg solves ──────────────────────────────────
function recordEasterEggSolve(eggDate, solverKey, userId) {
function recordEasterEggSolve(eggDate, solverKey, userId) {
try {
try {
@@ -4692,106 +4226,13 @@ function countEasterEggSolves(eggDate) {
@@ -4692,106 +4226,13 @@ function countEasterEggSolves(eggDate) {
try { return get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate])?.n || 0; } catch { return 0; }
try { return get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate])?.n || 0; } catch { return 0; }
}
}
-// Record a chat-relay (external platform) user's activity; keeps the earliest
-// first_seen as their "join date". Keyed case-insensitively by platform+username.
-function recordRelayUser(platform, username) {
- if (!platform || !username) return;
- const key = String(username).toLowerCase();
- try {
- run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)
- VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
- ON CONFLICT(platform, username) DO UPDATE SET
- last_seen = CURRENT_TIMESTAMP,
- message_count = message_count + 1,
- display_name = excluded.display_name`,
- [String(platform).toLowerCase(), key, String(username)]);
- } catch { /* non-critical */ }
-}
-function getRelayUserByRowid(id) {
- return get('SELECT rowid AS id, * FROM relay_users WHERE rowid = ?', [id]) || null;
-}
-
-// Relay chat messages are stored with a "[Label] name" username + source_platform and a
-// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is
-// case-insensitive for ASCII in SQLite), scoped to their platform.
-function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }
-const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username LIKE ? ESCAPE '\\'`;
-function _relayMatchParams(platform, rawUsername) {
- return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];
-}
-
-// A relay user's message history (for the "Chat Logs" viewer).
-function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {
- let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;
- const params = _relayMatchParams(platform, rawUsername);
- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
- const rows = all(
- `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
- cm.source_platform, s.title AS stream_title
- FROM chat_messages cm LEFT JOIN streams s ON cm.stream_id = s.id
- WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,
- [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]
- );
- return { messages: rows, total };
-}
-
-// ── Anonymous chatters: chat logs (mirror the relay path) ──────────
-// Anon messages have user_id NULL and a stable anon_id = "anon<N>" (which also equals
-// their username).
-function anonSubjectId(anonId) {
- const m = /^anon(\d+)$/i.exec(String(anonId || ''));
- return m ? parseInt(m[1], 10) : 0;
-}
-
-// Anon meta for the context menu: first-seen (when their anon number was assigned)
-// and first-chat (their earliest chat message), plus total message count.
-function getAnonMeta(anonId) {
- const num = anonSubjectId(anonId);
- const map = num ? get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [num]) : null;
- const firstChat = get(
- `SELECT MIN(timestamp) AS t FROM chat_messages
- WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]
- )?.t || null;
- const count = get(
- `SELECT COUNT(*) AS c FROM chat_messages
- WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [String(anonId)]
- )?.c || 0;
- return {
- anon_id: anonId,
- anon_num: num || null,
- first_seen: (map && map.created_at) || firstChat, // fall back to first chat for legacy rows
- first_chat: firstChat,
- message_count: count,
- };
-}
-
-// An anon's message history (for the "Chat Logs" viewer).
-function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {
- let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
- const params = [String(anonId)];
- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
- const rows = all(
- `SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
- s.title AS stream_title
- FROM chat_messages cm LEFT JOIN streams s ON cm.stream_id = s.id
- WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,
- [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]
- );
- return { messages: rows, total };
-}
-
function getUserProfile(userId) {
function getUserProfile(userId) {
const user = get(`SELECT id, username, display_name, avatar_url, profile_color, role,
const user = get(`SELECT id, username, display_name, avatar_url, profile_color, role,
openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen
openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen
FROM users WHERE id = ?`, [userId]);
FROM users WHERE id = ?`, [userId]);
if (!user) return null;
if (!user) return null;
- // The user's chat total is OpenVibe.Chat's (Live's own tables when Live runs chat); a synchronous
- // peek answers the last good count, else null while Chat refreshes in the background. The profile
- // card omits the count when it is null rather than showing a cold mirror number as real.
+ // The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
+ // null while Chat refreshes in the background. The profile card omits the count when it is null.
user.messageCount = (() => { try { return require('../chat/chat-reads').userMessageCountPeek(userId); } catch { return null; } })();
user.messageCount = (() => { try { return require('../chat/chat-reads').userMessageCountPeek(userId); } catch { return null; } })();
user.followerCount = get('SELECT COUNT(*) as c FROM follows WHERE streamer_id = ?', [userId])?.c || 0;
user.followerCount = get('SELECT COUNT(*) as c FROM follows WHERE streamer_id = ?', [userId])?.c || 0;
user.followingCount = get('SELECT COUNT(*) as c FROM follows WHERE follower_id = ?', [userId])?.c || 0;
user.followingCount = get('SELECT COUNT(*) as c FROM follows WHERE follower_id = ?', [userId])?.c || 0;
@@ -5366,66 +4807,6 @@ function isUsernameReserved(username) {
@@ -5366,66 +4807,6 @@ function isUsernameReserved(username) {
return !!vk;
return !!vk;
}
}
-// ── Channel sound commands (viewer-uploadable) ───────────────
-function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {
- return run(
- `INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
- [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '']
- );
-}
-// Update the shared emote_code for all sounds under a command (an emote is per-command).
-function setChannelSoundEmote(ownerId, command, emoteCode) {
- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',
- [emoteCode || '', ownerId, String(command || '').toLowerCase()]);
-}
-
-function getChannelSounds(ownerId) {
- return all(
- 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',
- [ownerId]
- );
-}
-
-function getChannelSoundByCommand(ownerId, command) {
- // A command may have multiple uploaded sounds — pick one at random each play.
- return get(
- 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',
- [ownerId, String(command || '').toLowerCase()]
- );
-}
-
-function getChannelSoundById(id) {
- return get('SELECT * FROM channel_sounds WHERE id = ?', [id]);
-}
-
-function countChannelSounds(ownerId) {
- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);
- return row ? row.count : 0;
-}
-
-function countChannelSoundsByUploader(ownerId, uploaderId) {
- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);
- return row ? row.count : 0;
-}
-
-function deleteChannelSound(id) {
- return run('DELETE FROM channel_sounds WHERE id = ?', [id]);
-}
-
-// Rename a whole !command group (a command may hold several sounds).
-function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {
- return run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',
- [String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);
-}
-
-// Sounds attach emotes BY CODE — keep those references alive when an emote
-// is renamed so the streamer's emote+sound combos don't silently break.
-function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',
- [newCode || '', ownerId, oldCode]);
-}
-
// ── AI chatbot config (per streamer) ─────────────────────────
// ── AI chatbot config (per streamer) ─────────────────────────
const AI_CHATBOT_DEFAULTS = {
const AI_CHATBOT_DEFAULTS = {
enabled: 0,
enabled: 0,
@@ -5613,17 +4994,6 @@ function getAiViewerLogStats(channelUserId, sinceMin = 60) {
@@ -5613,17 +4994,6 @@ function getAiViewerLogStats(channelUserId, sinceMin = 60) {
SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cached
SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cached
FROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)`, [channelUserId, `-${sinceMin} minutes`]);
FROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)`, [channelUserId, `-${sinceMin} minutes`]);
}
}
-// Channel chat (all surfaces: native, relayed, bot lines) newer than an id — what the bots "see".
-function getChannelChatSince(channelUserId, afterId = 0, limit = 60) {
- return all(`SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp
- FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation')
- AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP)
- ORDER BY id DESC LIMIT ?`, [channelUserId, afterId, Math.max(1, Math.min(200, limit))]).reverse();
-}
-function getMaxChatMessageIdForChannel(channelUserId) {
- return get('SELECT COALESCE(MAX(id),0) AS m FROM chat_messages WHERE channel_user_id = ?', [channelUserId])?.m || 0;
-}
-
function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {
function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {
let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';
let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';
if (activeOnly) sql += ' AND is_active = 1';
if (activeOnly) sql += ' AND is_active = 1';
@@ -5923,22 +5293,6 @@ function renormalizePendingMediaRequestPositions(streamerId) {
@@ -5923,22 +5293,6 @@ function renormalizePendingMediaRequestPositions(streamerId) {
// table is read-only: the one-time import (OpenVibe.Community scripts/import-live-comments.js)
// table is read-only: the one-time import (OpenVibe.Community scripts/import-live-comments.js)
// reads it; test/frozen-tables.test.js fails if server code writes it again.
// reads it; test/frozen-tables.test.js fails if server code writes it again.
-// ── Chat replay helpers ──────────────────────────────────────
-
-function getChatReplay(streamId, fromTime, toTime) {
- let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name
- FROM chat_messages cm
- LEFT JOIN users u ON cm.user_id = u.id
- WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
- const params = [streamId];
- // The same bounds as a purge (see deleteChatMessagesByTimeRange): either timestamp form, as UTC.
- if (fromTime) { sql += ` AND cm.timestamp >= datetime(?)`; params.push(fromTime); }
- if (toTime) { sql += ` AND cm.timestamp <= datetime(?)`; params.push(toTime); }
- sql += ` ORDER BY cm.timestamp ASC`;
- return all(sql, params);
-}
-
// ── Channel lookup by ID ─────────────────────────────────────
// ── Channel lookup by ID ─────────────────────────────────────
function getChannelById(id) {
function getChannelById(id) {
@@ -6038,230 +5392,6 @@ function loadAnonMappings() {
@@ -6038,230 +5392,6 @@ function loadAnonMappings() {
return { maxNum, mappings };
return { maxNum, mappings };
}
}
-// ── Stream First Chats (Welcome Messages) ────────────────────
-
-/**
- * Check if a chatter has ever chatted in this streamer's channel.
- * @param {string} chatterKey - e.g. "user:42" or "anon:anon3" or "ext:[Twitch] foo"
- * @param {number} channelUserId - the streamer's user ID
- * @returns {boolean} true if this is their first time
- */
-function isFirstChatInChannel(chatterKey, channelUserId) {
- const row = get(
- 'SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
- [chatterKey, channelUserId]
- );
- return !row;
-}
-
-/**
- * Record that a chatter has chatted in a streamer's channel.
- */
-function recordFirstChat(chatterKey, channelUserId) {
- run(
- 'INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)',
- [chatterKey, channelUserId]
- );
-}
-
-// ── Moderation Action Logging ────────────────────────────────
-
-/**
- * Log a moderation action for auditing.
- * Used by canvas, chat moderation, bans, etc.
- *
- * A staff or channel-moderator action also goes to OpenVibe.Network's moderation audit log as
- * live.moderation.action (Contracts 0.46.0, ADR-022), written to the outbox in the same transaction
- * as the row. Someone tidying their own messages (self_*), configuring their own channel, or acting
- * on themselves is not moderation and stays local.
- */
-function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
- const d = getDb();
- const row = [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {})];
- let announced = false;
- const info = d.transaction(() => {
- const r = d.prepare(`
- INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details)
- VALUES (?, ?, ?, ?, ?, ?)
- `).run(...row);
- announced = _announceModerationAction(Number(r.lastInsertRowid), { scope_type: row[0], scope_id: row[1], actor_user_id: row[2], target_user_id: row[3], action_type, details });
- return r;
- })();
- if (announced) require('../events/stream-events').kick();
- return info;
-}
-
-function _announceModerationAction(id, a, { at = null } = {}) {
- const type = String(a.action_type || '');
- if (!type || type.startsWith('self_') || type === 'channel_settings_update') return false;
- if (a.actor_user_id != null && a.actor_user_id === a.target_user_id) return false;
- const events = require('../events/stream-events');
- if (!events.status().enabled) return false;
- const subjectOf = (uid) => {
- if (uid == null) return null;
- const r = getDb().prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(uid);
- return r && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(r.subject_id || '') ? r.subject_id : null;
- };
- const actorSubject = subjectOf(a.actor_user_id);
- const int = (v) => (v == null || v === '' || !Number.isSafeInteger(Number(v)) ? null : Number(v));
- const scopeId = a.scope_id == null || a.scope_id === '' ? null : (Number.isSafeInteger(Number(a.scope_id)) ? Number(a.scope_id) : String(a.scope_id).slice(0, 128));
- const details = a.details && typeof a.details === 'object' && !Array.isArray(a.details) ? a.details : {};
- events.enqueue({
- ...(at ? { timestamp: at } : {}),
- event_type: 'live.moderation.action',
- actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'live' },
- subject: { type: 'moderation_action', id: String(id) },
- visibility: 'internal',
- priority: 'important',
- payload: {
- action_id: id, action_type: type.slice(0, 64), scope_type: String(a.scope_type || 'site').slice(0, 32), scope_id: scopeId,
- actor_user_id: int(a.actor_user_id), actor_subject: actorSubject,
- target_user_id: int(a.target_user_id), target_subject: subjectOf(a.target_user_id),
- details,
- },
- });
- return true;
-}
-
-/**
- * Get moderation actions with optional filters.
- */
-function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {
- const conditions = [];
- const params = [];
- const st = scopeType || scope_type;
- const si = scopeId || scope_id;
- if (st) { conditions.push('ma.scope_type = ?'); params.push(st); }
- if (si) { conditions.push('ma.scope_id = ?'); params.push(si); }
- if (actor_user_id) { conditions.push('ma.actor_user_id = ?'); params.push(actor_user_id); }
- if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }
- const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
- params.push(limit, offset);
- return all(`
- SELECT ma.*, actor.username AS actor_username, target.username AS target_username
- FROM moderation_actions ma
- LEFT JOIN users actor ON ma.actor_user_id = actor.id
- LEFT JOIN users target ON ma.target_user_id = target.id
- ${where}
- ORDER BY ma.created_at DESC
- LIMIT ? OFFSET ?
- `, params);
-}
-
-/**
- * Get a single chat message by ID.
- */
-function getChatMessageById(id) {
- return get('SELECT * FROM chat_messages WHERE id = ?', [id]);
-}
-
-/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */
-function mergeChatMessageMetadata(id, patch) {
- if (!id || !patch || typeof patch !== 'object') return;
- const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);
- if (!row) return;
- let meta = {};
- try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }
- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
- return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);
-}
-
-/**
- * Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it.
- */
-function deleteChatMessage(id, deletedBy = null) {
- return run(
- 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?',
- [deletedBy, id]
- );
-}
-
-/**
- * Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.
- * Returns the list of deleted message IDs for real-time broadcast.
- */
-function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {
- const condition = streamId
- ? 'user_id = ? AND stream_id = ? AND is_deleted = 0'
- : 'user_id = ? AND is_deleted = 0';
- const params = streamId ? [userId, streamId] : [userId];
- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
- const ids = messages.map(m => m.id);
- if (ids.length === 0) return [];
- const placeholders = ids.map(() => '?').join(',');
- run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
- [deletedBy, ...ids]
- );
- return ids;
-}
-
-/**
- * Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.
- */
-function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {
- const condition = streamId
- ? 'anon_id = ? AND stream_id = ? AND is_deleted = 0'
- : 'anon_id = ? AND is_deleted = 0';
- const params = streamId ? [anonId, streamId] : [anonId];
- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
- const ids = messages.map(m => m.id);
- if (ids.length === 0) return [];
- const placeholders = ids.map(() => '?').join(',');
- run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
- [deletedBy, ...ids]
- );
- return ids;
-}
-
-/**
- * Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")
- */
-function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {
- const condition = streamId
- ? 'username = ? AND stream_id = ? AND is_deleted = 0'
- : 'username = ? AND is_deleted = 0';
- const params = streamId ? [username, streamId] : [username];
- const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
- const ids = messages.map(m => m.id);
- if (ids.length === 0) return [];
- const placeholders = ids.map(() => '?').join(',');
- run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
- [deletedBy, ...ids]
- );
- return ids;
-}
-
-function deleteExpiredChatMessages(limit = 500) {
- const rows = all(
- `SELECT id, stream_id
- FROM chat_messages
- WHERE is_deleted = 0
- AND auto_delete_at IS NOT NULL
- -- Compared raw, not through datetime(): wrapping the column in a function makes the
- -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
- -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
- -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.
- AND auto_delete_at <= CURRENT_TIMESTAMP
- ORDER BY auto_delete_at ASC
- LIMIT ?`,
- [Math.max(1, Number(limit) || 500)]
- );
- if (!rows.length) return [];
-
- const ids = rows.map(row => row.id);
- const placeholders = ids.map(() => '?').join(',');
- run(
- `UPDATE chat_messages
- SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP
- WHERE id IN (${placeholders})`,
- ids
- );
- return rows;
-}
-
// ── Approved IPs (Anti-VPN Mode) ─────────────────────────────
// ── Approved IPs (Anti-VPN Mode) ─────────────────────────────
/**
/**
@@ -6300,153 +5430,6 @@ function getApprovedIps(channelId, { limit = 100, offset = 0 } = {}) {
@@ -6300,153 +5430,6 @@ function getApprovedIps(channelId, { limit = 100, offset = 0 } = {}) {
);
);
}
}
-/**
- * Hold a message for IP approval.
- */
-function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {
- return run(
- `INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
- [channelId, streamId, ip, userId || null, anonId || null, username, message]
- );
-}
-
-/**
- * Get pending messages for a channel.
- */
-function getPendingIpMessages(channelId, { limit = 50 } = {}) {
- return all(
- `SELECT * FROM pending_ip_messages WHERE channel_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT ?`,
- [channelId, limit]
- );
-}
-
-/**
- * Approve or deny a pending IP message. If approved, auto-approve the IP too.
- */
-function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
- // Scoped to the channel the caller was authorised for, so a message id from another channel's
- // queue matches nothing.
- const scoped = channelId != null;
- const res = scoped
- ? run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])
- : run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);
- if (!res.changes) return;
- if (status === 'approved') {
- const msg = get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);
- if (msg) approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');
- }
-}
-
-/**
- * Bulk-approve all pending messages from a specific IP in a channel.
- */
-function approveAllFromIp(channelId, ip, reviewedBy) {
- approveIp(channelId, ip, reviewedBy, 'manual');
- return run(
- "UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
- [reviewedBy, channelId, ip]
- );
-}
-
-/**
- * Deny all pending messages from a specific IP in a channel.
- */
-function denyAllFromIp(channelId, ip, reviewedBy) {
- return run(
- "UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
- [reviewedBy, channelId, ip]
- );
-}
-
-// ── Hidden Relay Users ───────────────────────────────────────
-
-/**
- * Hide or ban a relayed external user.
- */
-function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {
- return run(
- `INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)
- VALUES (?, ?, ?, ?, ?, ?)`,
- [channelId || null, platform, externalUsername, action, reason || null, createdBy]
- );
-}
-
-/**
- * Check if a relayed user is hidden/banned.
- * Checks both channel-scoped and site-wide entries (channel_id IS NULL).
- */
-function isRelayUserHidden(channelId, platform, externalUsername) {
- return !!get(
- `SELECT 1 FROM hidden_relay_users
- WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,
- [platform, externalUsername, channelId]
- );
-}
-
-/**
- * Unhide/unban a relayed external user.
- */
-function unhideRelayUser(id) {
- return run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);
-}
-
-/**
- * Unhide/unban a relayed external user by identity (platform + external username +
- * channel). Scoped to a single relay identity — never touches a registered
- * openvibelive account of the same name. `channel_id IS ?` matches NULL safely.
- */
-function unhideRelayUserByIdentity(channelId, platform, externalUsername) {
- return run(
- 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?',
- [platform, externalUsername, channelId || null]
- );
-}
-
-/**
- * Get hidden relay users for a channel (includes site-wide).
- */
-function getHiddenRelayUsers(channelId, { limit = 100 } = {}) {
- return all(
- `SELECT hru.*, u.username as created_by_username
- FROM hidden_relay_users hru LEFT JOIN users u ON hru.created_by = u.id
- WHERE hru.channel_id = ? OR hru.channel_id IS NULL
- ORDER BY hru.created_at DESC LIMIT ?`,
- [channelId, limit]
- );
-}
-
-/**
- * Search chat messages within a specific channel's streams.
- */
-function searchChannelChatMessages(channelId, { query, userId, limit = 50, offset = 0 } = {}) {
- const conditions = [
- 'cm.stream_id IN (SELECT id FROM streams WHERE channel_id = ?)',
- 'cm.is_deleted = 0',
- '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',
- ];
- const params = [channelId];
- if (query) {
- conditions.push('cm.message LIKE ?');
- params.push(`%${query}%`);
- }
- if (userId) {
- conditions.push('cm.user_id = ?');
- params.push(userId);
- }
- params.push(limit, offset);
- return {
- messages: all(`
- SELECT cm.*, u.username, u.display_name, u.avatar_url
- FROM chat_messages cm
- LEFT JOIN users u ON cm.user_id = u.id
- WHERE ${conditions.join(' AND ')}
- ORDER BY cm.created_at DESC
- LIMIT ? OFFSET ?
- `, params),
- };
-}
-
// ── IP Tracking ──────────────────────────────────────────────
// ── IP Tracking ──────────────────────────────────────────────
/**
/**
@@ -6822,16 +5805,6 @@ function getChannelAnalyticsSummary(userId, days) {
@@ -6822,16 +5805,6 @@ function getChannelAnalyticsSummary(userId, days) {
};
};
}
}
-function getRecentChatActivity(streamId, minutes) {
- const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();
- const row = get(
- `SELECT COUNT(*) as cnt FROM chat_messages
- WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,
- [streamId, cutoff]
- );
- return row?.cnt || 0;
-}
-
/* ── User Preferences (server-side settings sync) ─────────── */
/* ── User Preferences (server-side settings sync) ─────────── */
function getUserPreferences(userId) {
function getUserPreferences(userId) {
@@ -6850,72 +5823,6 @@ function saveUserPreferences(userId, chatSettings) {
@@ -6850,72 +5823,6 @@ function saveUserPreferences(userId, chatSettings) {
);
);
}
}
-/* ── Chat Log Management ──────────────────────────────────── */
-
-// Time ranges (purge, its preview, the log filter, chat replay): the dashboard sends ISO instants
-// ('…T…Z') and rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a
-// range matched nothing on its first day and all of its last: a purge removed lines outside the range
-// the streamer chose and kept the ones inside it (and VOD chat replay showed them). datetime(?) reads
-// both forms as UTC. OpenVibe.Chat, which serves these routes with CHAT_AUTHORITY=chat, does the same.
-function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
- if (streamId) {
- return run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
- WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
- [deletedBy, streamId, fromTime, toTime]
- );
- }
- // Global chat (is_global = 1)
- return run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
- WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
- [deletedBy, fromTime, toTime]
- );
-}
-
-function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
- let row;
- if (streamId) {
- row = get(
- `SELECT COUNT(*) as cnt FROM chat_messages
- WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
- [streamId, fromTime, toTime]
- );
- } else {
- row = get(
- `SELECT COUNT(*) as cnt FROM chat_messages
- WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
- [fromTime, toTime]
- );
- }
- return row?.cnt || 0;
-}
-
-function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {
- const conditions = [];
- const params = [];
-
- if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
- if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
- if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
- if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }
- if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }
- if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
- if (!includeDeleted) { conditions.push('is_deleted = 0'); }
-
- const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
- const offset = (page - 1) * limit;
-
- const countRow = get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);
- const total = countRow?.total || 0;
- const rows = all(
- `SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
- [...params, limit, offset]
- );
-
- return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
-}
-
/* ── API Tokens (Bot / Integration auth) ──────────────────── */
/* ── API Tokens (Bot / Integration auth) ──────────────────── */
function _hashToken(rawToken) {
function _hashToken(rawToken) {
@@ -7019,8 +5926,8 @@ module.exports = {
@@ -7019,8 +5926,8 @@ module.exports = {
getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getVodAiState, getClipAiState, forgetMediaItem,
getVodAiState, getClipAiState, forgetMediaItem,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
- getDb, initDb, run, get, all, close, announceModerationAction: _announceModerationAction,
- mergeChatMessageMetadata, getTimelineSpeechSince,
+ getDb, initDb, run, get, all, close,
+ getTimelineSpeechSince,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,
recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
@@ -7045,7 +5952,6 @@ module.exports = {
@@ -7045,7 +5952,6 @@ module.exports = {
cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
cleanupMalformedAiText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
- getChatSpikeOffsets, getLiveChatBuckets, getRecentChatText,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, getAllStreamerOverviews, getStreamersNeedingOverview,
readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
readStreamerAiTimelineCache, buildStreamerAiTimeline, assembleStreamerAiTimeline, setStreamAiTitle, getUntitledAiSessions, clearAiTimelineCache,
// Homepage helpers
// Homepage helpers
@@ -7074,10 +5980,7 @@ module.exports = {
@@ -7074,10 +5980,7 @@ module.exports = {
getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
getSubscriptionsDueRenewal,
getSubscriptionsDueRenewal,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
- // Chat
- saveChatMessage, searchChatMessages, getUserChatHistory, getChatSamplesInChannel,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
- getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,
// Profiles
// Profiles
getUserProfile, updateUserAvatar,
getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
getKickChannelCache, setKickChannelCache,
@@ -7115,8 +6018,6 @@ module.exports = {
@@ -7115,8 +6018,6 @@ module.exports = {
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
// Bans
isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache, forgiveBan,
isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache, forgiveBan,
- createChannelSound, setChannelSoundEmote, getChannelSounds, getChannelSoundByCommand, getChannelSoundById, renameChannelSoundCommand, updateChannelSoundEmoteRefs,
- countChannelSounds, countChannelSoundsByUploader, deleteChannelSound,
getAiChatbotConfig, upsertAiChatbotConfig,
getAiChatbotConfig, upsertAiChatbotConfig,
getChannelAiConfig, upsertChannelAiConfig,
getChannelAiConfig, upsertChannelAiConfig,
createChannelAiBot, getChannelAiBot, getChannelAiBots, getChannelAiBotByUsername,
createChannelAiBot, getChannelAiBot, getChannelAiBots, getChannelAiBotByUsername,
@@ -7125,42 +6026,26 @@ module.exports = {
@@ -7125,42 +6026,26 @@ module.exports = {
getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,
getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,
saveVodTranscriptProgress, getVodTranscriptProgress,
saveVodTranscriptProgress, getVodTranscriptProgress,
createAiViewerThread, getOpenAiViewerThreads, getRecentClosedAiViewerThreads, touchAiViewerThread, closeAiViewerThread, closeStaleAiViewerThreads, closeAllAiViewerThreads,
createAiViewerThread, getOpenAiViewerThreads, getRecentClosedAiViewerThreads, touchAiViewerThread, closeAiViewerThread, closeStaleAiViewerThreads, closeAllAiViewerThreads,
- addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats, getChannelChatSince, getMaxChatMessageIdForChannel,
+ addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats,
getState, setState, deleteState,
getState, setState, deleteState,
// Verification Keys
// Verification Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
- // Chat Replay
- getChatReplay,
// Channel lookup
// Channel lookup
getChannelById,
getChannelById,
getUserTotalGameLevel, getLegacyGameProfile,
getUserTotalGameLevel, getLegacyGameProfile,
// Anon IP Mappings
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
- // Stream first chats (welcome messages)
- isFirstChatInChannel, recordFirstChat,
- // Moderation Action Logging
- logModerationAction, getModerationActions,
- getChatMessageById, deleteChatMessage, searchChannelChatMessages,
- deleteUserChatMessages, deleteAnonChatMessages, deleteRelayUserMessages, deleteExpiredChatMessages,
// Approved IPs (Anti-VPN)
// Approved IPs (Anti-VPN)
isIpApproved, approveIp, revokeIpApproval, getApprovedIps,
isIpApproved, approveIp, revokeIpApproval, getApprovedIps,
- holdMessageForApproval, getPendingIpMessages, reviewPendingIpMessage,
- approveAllFromIp, denyAllFromIp,
- // Hidden Relay Users
- hideRelayUser, isRelayUserHidden, unhideRelayUser, unhideRelayUserByIdentity, getHiddenRelayUsers, recordRelayUser,
- getRelayUserByRowid, getRelayUserChatHistory,
- anonSubjectId, getAnonMeta, getAnonChatHistory,
// IP Tracking
// IP Tracking
logIp, getIpsByUser, getUsersByIp, getLinkedAccounts, getLinkedAccountsByAnon,
logIp, getIpsByUser, getUsersByIp, getLinkedAccounts, getLinkedAccountsByAnon,
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
// Stream Analytics
// Stream Analytics
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
- getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary, getRecentChatActivity,
+ getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary,
// User Preferences
// User Preferences
getUserPreferences, saveUserPreferences,
getUserPreferences, saveUserPreferences,
- // Chat Log Management
- deleteChatMessagesByTimeRange, countChatMessagesByTimeRange, getChatLogs,
// API Tokens
// API Tokens
createApiToken, listApiTokens, revokeApiToken, validateApiToken,
createApiToken, listApiTokens, revokeApiToken, validateApiToken,
};
};
@@ -187,6 +187,57 @@ const OPERATOR_MIGRATIONS = [
@@ -187,6 +187,57 @@ const OPERATOR_MIGRATIONS = [
db.exec('DROP TABLE IF EXISTS chat_bridge_outbox');
db.exec('DROP TABLE IF EXISTS chat_bridge_outbox');
},
},
},
},
+ {
+ id: 'op_003_drop_chat_tables',
+ // T3 final step. OpenVibe.Chat owns these twelve tables and has been their only writer since
+ // the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept); Live's readers
+ // and writers are gone (PRs #28–#32 and the release that carries this migration), and this
+ // release no longer creates them. The N-1 release still runs SQL over them
+ // (test/n-1.test.js shows it), so the contract step is operator-run: once N-1 is out of
+ // rollback range, scripts/chat-tables-drop.js --apply takes an online backup and runs this.
+ // Dropping a table removes its own indexes; the explicit DROP INDEX lines also clear index
+ // names older rebuilds left behind. dm_participants/dm_messages go before dm_conversations
+ // (their FK parent). No trigger was ever created on any of the twelve.
+ up: (db) => {
+ db.exec(`
+ DROP INDEX IF EXISTS idx_chat_stream_id;
+ DROP INDEX IF EXISTS idx_chat_timestamp;
+ DROP INDEX IF EXISTS idx_chat_user_id;
+ DROP INDEX IF EXISTS idx_chat_stream;
+ DROP INDEX IF EXISTS idx_chat_user;
+ DROP INDEX IF EXISTS idx_chat_channel_user_ts;
+ DROP INDEX IF EXISTS idx_chat_stream_ts;
+ DROP INDEX IF EXISTS idx_chat_autodelete;
+ DROP INDEX IF EXISTS idx_chat_ts_deleted;
+ DROP INDEX IF EXISTS idx_channel_sounds_owner;
+ DROP INDEX IF EXISTS idx_channel_sounds_cmd;
+ DROP INDEX IF EXISTS idx_mod_actions_created;
+ DROP INDEX IF EXISTS idx_mod_actions_actor;
+ DROP INDEX IF EXISTS idx_mod_actions_scope;
+ DROP INDEX IF EXISTS idx_pending_ip_channel;
+ DROP INDEX IF EXISTS idx_hidden_relay_channel;
+ DROP INDEX IF EXISTS idx_sfc_channel;
+ DROP INDEX IF EXISTS idx_dm_participants_conv;
+ DROP INDEX IF EXISTS idx_dm_participants_user;
+ DROP INDEX IF EXISTS idx_dm_messages_conv;
+ DROP INDEX IF EXISTS idx_dm_messages_sender;
+ DROP INDEX IF EXISTS idx_dm_blocks_blocker;
+ DROP INDEX IF EXISTS idx_dm_blocks_blocked;
+ DROP TABLE IF EXISTS dm_participants;
+ DROP TABLE IF EXISTS dm_messages;
+ DROP TABLE IF EXISTS dm_conversations;
+ DROP TABLE IF EXISTS dm_blocks;
+ DROP TABLE IF EXISTS chat_messages;
+ DROP TABLE IF EXISTS tts_voice_overrides;
+ DROP TABLE IF EXISTS channel_sounds;
+ DROP TABLE IF EXISTS relay_users;
+ DROP TABLE IF EXISTS hidden_relay_users;
+ DROP TABLE IF EXISTS pending_ip_messages;
+ DROP TABLE IF EXISTS stream_first_chats;
+ DROP TABLE IF EXISTS moderation_actions;
+ `);
+ },
+ },
];
];
const failures = new Map(); // id -> message, for this process
const failures = new Map(); // id -> message, for this process
@@ -145,29 +145,6 @@ CREATE TABLE IF NOT EXISTS cameras (
@@ -145,29 +145,6 @@ CREATE TABLE IF NOT EXISTS cameras (
FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE
FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE
);
);
-CREATE TABLE IF NOT EXISTS chat_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- stream_id INTEGER,
- user_id INTEGER, -- NULL for anon
- anon_id TEXT, -- 'anon12345' format
- username TEXT,
- message TEXT NOT NULL,
- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts')),
- metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)
- is_global INTEGER DEFAULT 0,
- is_deleted INTEGER DEFAULT 0,
- is_filtered INTEGER DEFAULT 0,
- reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,
- source_platform TEXT,
- deleted_by INTEGER,
- deleted_at DATETIME,
- auto_delete_at DATETIME,
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
-);
-
CREATE TABLE IF NOT EXISTS vibe_coding_sessions (
CREATE TABLE IF NOT EXISTS vibe_coding_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
managed_stream_id INTEGER NOT NULL,
managed_stream_id INTEGER NOT NULL,
@@ -485,9 +462,6 @@ CREATE INDEX IF NOT EXISTS idx_streams_user_id ON streams(user_id);
@@ -485,9 +462,6 @@ CREATE INDEX IF NOT EXISTS idx_streams_user_id ON streams(user_id);
CREATE INDEX IF NOT EXISTS idx_streams_is_live ON streams(is_live);
CREATE INDEX IF NOT EXISTS idx_streams_is_live ON streams(is_live);
CREATE INDEX IF NOT EXISTS idx_streams_channel_id ON streams(channel_id);
CREATE INDEX IF NOT EXISTS idx_streams_channel_id ON streams(channel_id);
CREATE INDEX IF NOT EXISTS idx_channels_user_id ON channels(user_id);
CREATE INDEX IF NOT EXISTS idx_channels_user_id ON channels(user_id);
-CREATE INDEX IF NOT EXISTS idx_chat_stream_id ON chat_messages(stream_id);
-CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);
-CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);
CREATE INDEX IF NOT EXISTS idx_follows_streamer ON follows(streamer_id);
CREATE INDEX IF NOT EXISTS idx_follows_streamer ON follows(streamer_id);
CREATE INDEX IF NOT EXISTS idx_transactions_to_user ON transactions(to_user_id);
CREATE INDEX IF NOT EXISTS idx_transactions_to_user ON transactions(to_user_id);
CREATE INDEX IF NOT EXISTS idx_vods_user_id ON vods(user_id);
CREATE INDEX IF NOT EXISTS idx_vods_user_id ON vods(user_id);
@@ -545,24 +519,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_content ON comments(content_type, conten
@@ -545,24 +519,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_content ON comments(content_type, conten
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
-CREATE TABLE IF NOT EXISTS channel_sounds (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to
- command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased
- url TEXT NOT NULL, -- served path of the audio file on disk
- mime TEXT DEFAULT 'audio/mpeg',
- duration_seconds REAL DEFAULT 0,
- created_by INTEGER, -- uploader user id (NULL if removed user)
- created_by_name TEXT DEFAULT '',
- is_approved INTEGER DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- -- Multiple sounds may share a command; playback picks one at random.
- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
-CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
-
-- ═══════════════════════════════════════════════════════════════
-- ═══════════════════════════════════════════════════════════════
-- OpenCoins (Channel Points — free loyalty currency)
-- OpenCoins (Channel Points — free loyalty currency)
-- Earned by watching streams, chatting, etc. Global across all streams.
-- Earned by watching streams, chatting, etc. Global across all streams.
@@ -1188,9 +1188,7 @@ async function start() {
@@ -1188,9 +1188,7 @@ async function start() {
cosmeticsModule.ensureTables();
cosmeticsModule.ensureTables();
// Chat tag tables (read-only tags, server/chat/tags.js)
// Chat tag tables (read-only tags, server/chat/tags.js)
require('./chat/tags').ensureTagTables();
require('./chat/tags').ensureTagTables();
- // Initialize DM tables
- const dm = require('./chat/dm');
- dm.ensureTables();
+ // (Live used to create DM tables here; OpenVibe.Chat owns them, and Live keeps no copy.)
// Migrate: add last_heartbeat column if missing
// Migrate: add last_heartbeat column if missing
try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }
try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }
// Migrate: add theme_id to users table if missing
// Migrate: add theme_id to users table if missing
@@ -1655,7 +1653,6 @@ function startDrill() {
@@ -1655,7 +1653,6 @@ function startDrill() {
db.initDb();
db.initDb();
cosmeticsModule.ensureTables();
cosmeticsModule.ensureTables();
require('./chat/tags').ensureTagTables();
require('./chat/tags').ensureTagTables();
- require('./chat/dm').ensureTables();
console.log(`[Drill] Database ready: ${paths.dbPath()}`);
console.log(`[Drill] Database ready: ${paths.dbPath()}`);
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
@@ -391,10 +391,9 @@ class ChatRelayService {
@@ -391,10 +391,9 @@ class ChatRelayService {
const color = PLATFORM_COLORS[bridge.platform] || '#888';
const color = PLATFORM_COLORS[bridge.platform] || '#888';
const prefixedUsername = `[${label}] ${username}`;
const prefixedUsername = `[${label}] ${username}`;
- // Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached
- // peek — this path must not wait on an HTTP call): the fresh list, else the last good one.
- // A cold cache fails open, so a Chat outage never drops relayed lines; Live's own frozen
- // table is consulted only when Live is not in chat mode.
+ // Check if this relay user is hidden/banned. Chat's queue answers (a cached peek — this
+ // path must not wait on an HTTP call): the fresh list, else the last good one. A cold cache
+ // fails open, so a Chat outage never drops relayed lines; Live keeps no local copy.
try {
try {
const stream = db.getStreamById(bridge.streamId);
const stream = db.getStreamById(bridge.streamId);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
@@ -403,74 +402,11 @@ class ChatRelayService {
@@ -403,74 +402,11 @@ class ChatRelayService {
}
}
} catch { /* non-critical — allow message through on error */ }
} catch { /* non-critical — allow message through on error */ }
+ // Chat persists, broadcasts, records the first chat and reads it aloud in one call.
+ // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of
+ // the send must not stall it. The catch keeps a surprise rejection off the process.
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) {
- // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of
- // the send must not stall it. The catch keeps a surprise rejection off the process.
- return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });
- }
-
- // Record this relay user's activity; their first message becomes a join date.
- try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }
-
- const chatMsg = {
- type: 'chat',
- username: prefixedUsername,
- user_id: null,
- anon_id: null,
- role: 'external',
- message: String(message || ''),
- stream_id: bridge.streamId,
- is_global: false,
- avatar_url: extras.avatar_url || null,
- profile_color: color,
- timestamp: extras.timestamp || new Date().toISOString(),
- source_platform: bridge.platform,
- };
-
- try {
- const result = db.saveChatMessage({
- stream_id: bridge.streamId,
- user_id: null,
- anon_id: null,
- username: prefixedUsername,
- message: chatMsg.message,
- message_type: 'chat',
- is_global: 0,
- source_platform: bridge.platform,
- });
- if (result?.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
- } catch {}
-
- 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 { delivery.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }
- // And to viewers of the streamer's other live slots (cross-slot chat)
- try { delivery.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }
-
- // Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
- try {
- 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');
-
- // Welcome first-time external chatters in this streamer's channel
- try {
- const stream = db.getStreamById(bridge.streamId);
- if (stream?.user_id) {
- const chatterKey = `ext:${prefixedUsername}`;
- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
- db.recordFirstChat(chatterKey, stream.user_id);
- delivery.broadcastToStream(bridge.streamId, {
- type: 'system',
- message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
- timestamp: new Date().toISOString(),
- });
- }
- }
- } catch { /* non-critical */ }
+ return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });
}
}
/** What follows a relayed line once it has an id: AI viewers hear it, PowerChat's overlay shows it. */
/** What follows a relayed line once it has an id: AI viewers hear it, PowerChat's overlay shows it. */
@@ -91,13 +91,10 @@ function _testFulfillmentAllowed() {
@@ -91,13 +91,10 @@ function _testFulfillmentAllowed() {
}
}
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
-// A persisted chat line plus its live card: one Chat ingress call with CHAT_AUTHORITY=chat (Chat persists and
-// shows it; `mirror` also puts it in global chat), else the legacy card broadcast(s) and a local save.
-function _chatLine({ frame, line, mirror = false, key }) {
- if (delivery.ingress()) { delivery.message({ ...line, mirror, key }); return; }
- 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);
+// A persisted chat line: one Chat ingress call (Chat persists and shows it; `mirror` also puts it
+// in global chat). Live keeps no chat table.
+function _chatLine({ line, mirror = false, key }) {
+ delivery.message({ ...line, mirror, key });
}
}
function _handleDonation(userId, data) {
function _handleDonation(userId, data) {
@@ -111,7 +108,6 @@ function _handleDonation(userId, data) {
@@ -111,7 +108,6 @@ function _handleDonation(userId, data) {
const donor = String(data.donorName || 'Someone').slice(0, 80);
const donor = String(data.donorName || 'Someone').slice(0, 80);
const message = String(data.message || '').slice(0, 500);
const message = String(data.message || '').slice(0, 500);
const goalId = _goalIdFromDonation(data);
const goalId = _goalIdFromDonation(data);
- const ts = new Date().toISOString();
// If the streamer is live, attach to the live session so it lands in that slot too.
// If the streamer is live, attach to the live session so it lands in that slot too.
let streamId = null;
let streamId = null;
@@ -121,15 +117,11 @@ function _handleDonation(userId, data) {
@@ -121,15 +117,11 @@ function _handleDonation(userId, data) {
let goalResult = null;
let goalResult = null;
try { goalResult = openvibeBucks.applyDonationToGoal(userId, amount, goalId); } catch { /* */ }
try { goalResult = openvibeBucks.applyDonationToGoal(userId, amount, goalId); } catch { /* */ }
- // 1) Donation chat event — live + persisted to channel history.
- const donationEvent = {
- type: 'donation', username: donor, user_id: null, avatar_url: null,
- amount, message, source: 'powerchat', timestamp: ts,
- };
+ // 1) Donation chat event — persisted to channel history (and mirrored to global chat).
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
try {
try {
_chatLine({
_chatLine({
- frame: donationEvent, mirror: true, key: eventKey,
+ mirror: true, key: eventKey,
line: {
line: {
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
message: `${donor} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''} (PowerChat)`,
message: `${donor} tipped ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''} (PowerChat)`,
@@ -150,7 +142,6 @@ function _handleDonation(userId, data) {
@@ -150,7 +142,6 @@ function _handleDonation(userId, data) {
const g = goalResult.goal;
const g = goalResult.goal;
try {
try {
_chatLine({
_chatLine({
- frame: { type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts },
key: eventKey && `${eventKey}:goal`,
key: eventKey && `${eventKey}:goal`,
line: {
line: {
stream_id: streamId, channel_user_id: userId, user_id: null, username: 'Donation Goal',
stream_id: streamId, channel_user_id: userId, user_id: null, username: 'Donation Goal',
@@ -169,10 +160,8 @@ function _handleDonation(userId, data) {
@@ -169,10 +160,8 @@ function _handleDonation(userId, data) {
// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).
// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).
function _handleSubscription(userId, data) {
function _handleSubscription(userId, data) {
const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);
const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);
- const ts = new Date().toISOString();
try {
try {
_chatLine({
_chatLine({
- frame: { type: 'donation', username: name, amount: 0, message: 'subscribed via PowerChat', source: 'powerchat-sub', timestamp: ts },
key: data.eventId ? `powerchat:${data.eventId}` : undefined,
key: data.eventId ? `powerchat:${data.eventId}` : undefined,
line: {
line: {
stream_id: null, channel_user_id: userId, user_id: null, username: name,
stream_id: null, channel_user_id: userId, user_id: null, username: name,
@@ -186,9 +175,7 @@ function _handleSubscription(userId, data) {
@@ -186,9 +175,7 @@ function _handleSubscription(userId, data) {
// A lightweight chat notice for non-money events (follow / host / points redeem).
// A lightweight chat notice for non-money events (follow / host / points redeem).
function _handleNotice(userId, message, kind) {
function _handleNotice(userId, message, kind) {
try {
try {
- const ts = new Date().toISOString();
_chatLine({
_chatLine({
- frame: { type: 'system', message, source: 'powerchat', kind, timestamp: ts },
line: {
line: {
stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',
stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',
message, message_type: 'system', metadata: { kind: kind || 'powerchat', source: 'powerchat' },
message, message_type: 'system', metadata: { kind: kind || 'powerchat', source: 'powerchat' },
@@ -291,18 +278,13 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
@@ -291,18 +278,13 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
let streamId = null;
let streamId = null;
try { const live = db.getLiveStreamsByUserId(userId) || []; if (live.length) streamId = live[0].id; } catch { /* */ }
try { const live = db.getLiveStreamsByUserId(userId) || []; if (live.length) streamId = live[0].id; } catch { /* */ }
- const testEvent = {
- type: 'donation', username: donor, user_id: null, avatar_url: null,
- amount, message, source: 'powerchat-test', timestamp: ts,
- };
// Not to global chat: a test tip is the streamer's own preview, and any signed-in user could
// Not to global chat: a test tip is the streamer's own preview, and any signed-in user could
// otherwise announce a fake 99,900-Vibe donation site-wide.
// otherwise announce a fake 99,900-Vibe donation site-wide.
// Persist it, exactly like a real tip. A test that vanishes on reload does not
// Persist it, exactly like a real tip. A test that vanishes on reload does not
// actually test what the streamer is checking — that the alert lands in chat AND
// actually test what the streamer is checking — that the alert lands in chat AND
- // survives a refresh. This was the only donation path that broadcast without saving.
+ // survives a refresh.
try {
try {
_chatLine({
_chatLine({
- frame: testEvent,
line: {
line: {
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
message: `${donor} donated ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`,
message: `${donor} donated ${amount.toLocaleString()} Vibes${message ? ': ' + message : ''}`,
@@ -648,15 +648,11 @@ class RobotStreamerService {
@@ -648,15 +648,11 @@ class RobotStreamerService {
// Drop RS protocol/meta messages (e.g. {"user_name":"Goosely"})
// Drop RS protocol/meta messages (e.g. {"user_name":"Goosely"})
if (/^\s*\{.*\}\s*$/.test(msgText)) return;
if (/^\s*\{.*\}\s*$/.test(msgText)) return;
- const timestamp = Number.isFinite(Number(data.timestamp))
- ? new Date(Number(data.timestamp)).toISOString()
- : new Date().toISOString();
const rawUsername = String(data.username || 'anon');
const rawUsername = String(data.username || 'anon');
const username = `[RS] ${rawUsername}`;
const username = `[RS] ${rawUsername}`;
- // Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached
- // peek — fresh list, else the last good one; a cold cache fails open), Live's own
- // table only when Live is not in chat mode.
+ // Check if this relay user is hidden/banned: Chat's queue (a cached peek — fresh
+ // list, else the last good one; a cold cache fails open). Live keeps no local copy.
try {
try {
const channel = stream.channel_id
const channel = stream.channel_id
? db.getChannelById(stream.channel_id)
? db.getChannelById(stream.channel_id)
@@ -668,8 +664,7 @@ class RobotStreamerService {
@@ -668,8 +664,7 @@ class RobotStreamerService {
// Record this relay user (first message = join date) so RobotStreamer
// Record this relay user (first message = join date) so RobotStreamer
// chatters get the same chat logs + AI insight as other relay users.
// chatters get the same chat logs + AI insight as other relay users.
- if (delivery.ingress()) { delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }
- else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }
+ delivery.moderate('relay-record', { platform: 'rs', username: rawUsername });
// Let RobotStreamer viewers trigger channel !sound commands too. If the
// Let RobotStreamer viewers trigger channel !sound commands too. If the
// message is a registered !sound, play it (attributed to the RS user) and
// message is a registered !sound, play it (attributed to the RS user) and
@@ -681,77 +676,22 @@ class RobotStreamerService {
@@ -681,77 +676,22 @@ class RobotStreamerService {
// Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own
// Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own
// frozen table is never a fallback — null means no sound (or Chat unreachable).
// frozen table is never a fallback — null means no sound (or Chat unreachable).
if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {
if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {
- if (delivery.ingress()) {
- delivery.event({ kind: 'stream', id: stream.id }, {
- type: 'channel-sound', streamId: stream.id, command: scmd, args: parts.slice(1).map((a) => a.slice(0, 120)).slice(0, 20),
- relay: { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' },
- });
- return;
- }
- try {
- delivery.triggerChannelSound(
- null,
- { streamId: stream.id, user: null, anonId: null, ip: null },
- stream, scmd, parts.slice(1),
- { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' }
- );
- } catch { /* non-critical */ }
+ // Chat plays the sound (it owns channel_sounds); Live sends the trigger.
+ delivery.event({ kind: 'stream', id: stream.id }, {
+ type: 'channel-sound', streamId: stream.id, command: scmd, args: parts.slice(1).map((a) => a.slice(0, 120)).slice(0, 20),
+ relay: { username, role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || null, sourcePlatform: 'rs' },
+ });
return;
return;
}
}
}
}
- const mirrored = {
- type: 'chat',
- username,
- user_id: null,
- anon_id: null,
- role: 'external',
- message: msgText,
- stream_id: stream.id,
- is_global: false,
- avatar_url: data.avatar || null,
- profile_color: '#7dd3fc',
- timestamp,
- source_platform: 'rs',
- };
-
- if (delivery.ingress()) {
- // Chat persists, broadcasts, mirrors and reads it aloud in one call.
- delivery.after(delivery.message({
- stream_id: stream.id, username, message: msgText, message_type: 'chat', is_global: false,
- source_platform: 'rs', mirror: true,
- frame: { role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || undefined },
- tts: { identity_key: `rs:${username}` },
- }), (id) => rsFollowUps(stream, username, rawUsername, msgText, data.avatar, id));
- return;
- }
- try {
- const result = db.saveChatMessage({
- stream_id: stream.id,
- user_id: null,
- anon_id: null,
- username,
- message: mirrored.message,
- message_type: 'chat',
- is_global: 0,
- source_platform: 'rs',
- });
- if (result?.lastInsertRowid) mirrored.id = Number(result.lastInsertRowid);
- } catch {}
-
- 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 { delivery.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }
- // And to viewers of the streamer's other live slots (cross-slot chat)
- try { delivery.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }
-
- // Feed relayed RS chat into server-side TTS (same path as native chat)
- try {
- 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');
+ // Chat persists, broadcasts, mirrors and reads it aloud in one call.
+ delivery.after(delivery.message({
+ stream_id: stream.id, username, message: msgText, message_type: 'chat', is_global: false,
+ source_platform: 'rs', mirror: true,
+ frame: { role: 'external', profile_color: '#7dd3fc', avatar_url: data.avatar || undefined },
+ tts: { identity_key: `rs:${username}` },
+ }), (id) => rsFollowUps(stream, username, rawUsername, msgText, data.avatar, id));
});
});
bridge.ws.on('close', () => {
bridge.ws.on('close', () => {
@@ -1,11 +1,12 @@
@@ -1,11 +1,12 @@
/**
/**
- * asset-sync.js — mirror Live's channel sounds into OpenVibe.Media, their canonical public home
- * (served at /a/:id, browsable on the media index with uploader + channel attribution).
+ * asset-sync.js — mirror OpenVibe.Chat's channel sounds into OpenVibe.Media, their canonical public
+ * home (served at /a/:id, browsable on the media index with uploader + channel attribution).
*
*
- * Local files remain the low-latency working copy (chat playback reads disk); this job uploads
- * anything Media doesn't have yet and records media_url / media_asset_id on the local row, so
- * sound URLs come from Media's long-cached endpoint and deletes propagate. Idempotent: rows are
- * skipped once media_asset_id is set, and Media upserts on (kind, name, channel).
+ * Chat owns the channel_sounds rows; this job asks its pending-asset read for anything Media
+ * doesn't have yet, uploads the local file, and records media_url / media_asset_id back through
+ * Chat's sound-asset write, so sound URLs come from Media's long-cached endpoint and deletes
+ * propagate. Idempotent: rows are skipped once media_asset_id is set, and Media upserts on
+ * (kind, name, channel).
*
*
* The emote backfill went with roadmap T3: OpenVibe.Chat owns `emotes` and uploads emote bytes to
* The emote backfill went with roadmap T3: OpenVibe.Chat owns `emotes` and uploads emote bytes to
* Media with its own token, so Live no longer has emote rows to sync.
* Media with its own token, so Live no longer has emote rows to sync.
@@ -26,14 +27,6 @@ const MIME_BY_EXT = {
@@ -26,14 +27,6 @@ const MIME_BY_EXT = {
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.webm': 'audio/webm',
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.webm': 'audio/webm',
};
};
-function _ensureColumns() {
- for (const [table, col, type] of [
- ['channel_sounds', 'media_url', 'TEXT'], ['channel_sounds', 'media_asset_id', 'INTEGER'],
- ]) {
- try { db.getDb().exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); } catch { /* exists */ }
- }
-}
-
function _localFile(dir, urlOrPath) {
function _localFile(dir, urlOrPath) {
if (!urlOrPath) return null;
if (!urlOrPath) return null;
if (path.isAbsolute(urlOrPath) && fs.existsSync(urlOrPath)) return urlOrPath;
if (path.isAbsolute(urlOrPath) && fs.existsSync(urlOrPath)) return urlOrPath;
@@ -57,7 +50,6 @@ async function syncAll() {
@@ -57,7 +50,6 @@ async function syncAll() {
if (_running) return;
if (_running) return;
_running = true;
_running = true;
try {
try {
- _ensureColumns();
const nameCache = {};
const nameCache = {};
const uname = (id) => {
const uname = (id) => {
if (!id) return '';
if (!id) return '';
@@ -66,9 +58,9 @@ async function syncAll() {
@@ -66,9 +58,9 @@ async function syncAll() {
};
};
let synced = 0, failed = 0;
let synced = 0, failed = 0;
- // The work list is Chat's (channel_sounds is Chat's table); Live's own rows when Live runs
- // chat. Page by id until a short page: sounds with no local file stay pending and would
- // otherwise fill the first window forever, starving every later sound.
+ // The work list is Chat's (it owns channel_sounds). Page by id until a short page: sounds
+ // with no local file stay pending and would otherwise fill the first window forever,
+ // starving every later sound.
const PAGE = 100;
const PAGE = 100;
let afterId = 0;
let afterId = 0;
for (;;) {
for (;;) {
@@ -55,18 +55,6 @@ async function _sendOne(clipId) {
@@ -55,18 +55,6 @@ async function _sendOne(clipId) {
creator_color: (creator && creator.profile_color) || null,
creator_color: (creator && creator.profile_color) || null,
auto: !!clip.auto_generated,
auto: !!clip.auto_generated,
};
};
- const payload = {
- type: 'chat',
- message_type: 'clip',
- username: creatorName,
- user_id: null,
- message: `clipped: ${title}`,
- stream_id: streamId,
- channel_user_id: ownerId,
- is_global: false,
- clip: meta,
- timestamp: new Date().toISOString(),
- };
const line = {
const line = {
stream_id: streamId,
stream_id: streamId,
channel_user_id: ownerId,
channel_user_id: ownerId,
@@ -78,15 +66,8 @@ async function _sendOne(clipId) {
@@ -78,15 +66,8 @@ async function _sendOne(clipId) {
};
};
try {
try {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) {
- // Chat persists and shows the line (one key per clip, so a re-announce cannot post twice).
- await delivery.message({ ...line, key: `clip:${clipId}` });
- db.markClipNotifiedState(clipId);
- return;
- }
- const saved = db.saveChatMessage(line);
- payload.id = saved && saved.lastInsertRowid;
- delivery.broadcastToChannelRoom(ownerId, streamId, payload);
+ // Chat persists and shows the line (one key per clip, so a re-announce cannot post twice).
+ await delivery.message({ ...line, key: `clip:${clipId}` });
} catch (e) {
} catch (e) {
console.warn('[ClipNotify] send failed:', e.message);
console.warn('[ClipNotify] send failed:', e.message);
}
}
@@ -91,15 +91,9 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -91,15 +91,9 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
const alerts = require('./alerts');
const alerts = require('./alerts');
const donorUser = db.getUserById(req.user.id);
const donorUser = db.getUserById(req.user.id);
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
- const ts = new Date().toISOString();
- // 1) Donation chat message — broadcast live AND persist to channel history so
- // late-joiners see it. Channel-room broadcast reaches all slots + offline.
- const donationEvent = {
- type: 'donation', username: donor, user_id: req.user.id,
- avatar_url: donorUser?.avatar_url || null,
- amount: result.amount, message: message || '', timestamp: ts,
- };
+ // 1) Donation chat message — Chat persists it to channel history so late-joiners see
+ // it, and mirrors it into global chat.
const donationLine = {
const donationLine = {
stream_id: stream_id || null, channel_user_id: streamer_id, user_id: req.user.id,
stream_id: stream_id || null, channel_user_id: streamer_id, user_id: req.user.id,
username: donor,
username: donor,
@@ -107,16 +101,8 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -107,16 +101,8 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
message_type: 'donation',
message_type: 'donation',
metadata: { kind: 'donation', amount: result.amount, message: message || '', username: donor, user_id: req.user.id, avatar_url: donorUser?.avatar_url || null },
metadata: { kind: 'donation', amount: result.amount, message: message || '', username: donor, user_id: req.user.id, avatar_url: donorUser?.avatar_url || null },
};
};
- if (delivery.ingress()) {
- // 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 {
- 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 { delivery.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }
- try { db.saveChatMessage(donationLine); } catch { /* non-critical */ }
- }
+ // 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 });
// 2) Donation sound (streamer-configured).
// 2) Donation sound (streamer-configured).
alerts.playAlertSound(streamer_id, stream_id, 'donation');
alerts.playAlertSound(streamer_id, stream_id, 'donation');
@@ -149,13 +135,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -149,13 +135,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
message_type: 'donation',
message_type: 'donation',
metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },
metadata: { kind: 'goal-reached', goal_id: g.id, title: g.title, target: g.target_amount, image: g.image_url || null, media_type: g.media_type || null, by: donor },
};
};
- if (delivery.ingress()) delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });
- else {
- delivery.broadcastToChannelRoom(streamer_id, stream_id || null, {
- type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts,
- });
- try { db.saveChatMessage(goalLine); } catch { /* non-critical */ }
- }
+ delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });
alerts.playAlertSound(streamer_id, stream_id, 'goal');
alerts.playAlertSound(streamer_id, stream_id, 'goal');
}
}
@@ -131,16 +131,8 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -131,16 +131,8 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
message_type: 'donation',
message_type: 'donation',
metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },
metadata: { kind: 'donation', amount, message, username: name, source: 'tips', interaction_id: i.id || null, paid_message: event.paid_message, highlight_seconds: event.highlight_seconds, test: !!b.test },
};
};
- let chatMessageId = null;
- if (delivery.ingress()) {
- // 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 {
- 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;
- }
+ // Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
+ const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));
return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));
}
}
@@ -2,9 +2,10 @@
@@ -2,9 +2,10 @@
// Account export and deletion → Live (roadmap WS-B task 7, ADR-033): through POST /internal/network-events (signed),
// Account export and deletion → Live (roadmap WS-B task 7, ADR-033): through POST /internal/network-events (signed),
// network.account.export_requested sends Live's part to Network (the profile and the person's rows per table, no
// network.account.export_requested sends Live's part to Network (the profile and the person's rows per table, no
// stream keys or token hashes; an empty part for someone with no Live account), and network.account.deleted erases the
// stream keys or token hashes; an empty part for someone with no Live account), and network.account.deleted erases the
-// person's rows (follows and channel points both ways, streams with what is inside them, their own chat lines,
-// managed streams, tokens), keeps money and moderation rows pointing at a tombstone (the donation message cleared),
-// keeps shared rows without them, releases the username, and confirms with counts. A redelivery sends nothing twice;
+// person's rows (follows and channel points both ways, streams with what is inside them, managed streams, tokens),
+// keeps money rows pointing at a tombstone (the donation message cleared), keeps shared rows without them, releases
+// the username, and confirms with counts. (The chat tables are OpenVibe.Chat's and erased by its own eraser.)
+// A redelivery sends nothing twice;
// a failed confirmation is retried without erasing again. The subscription asks for both topics.
// a failed confirmation is retried without erasing again. The subscription asks for both topics.
// node test/account-data.test.js
// node test/account-data.test.js
const assert = require('assert');
const assert = require('assert');
@@ -37,13 +38,16 @@ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, servi
@@ -37,13 +38,16 @@ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, servi
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (20, 21), (22, 20), (22, 21)').run();
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (20, 21), (22, 20), (22, 21)').run();
d.prepare('INSERT INTO channel_points (user_id, streamer_id, balance) VALUES (20, 21, 30), (22, 20, 7), (22, 21, 5)').run();
d.prepare('INSERT INTO channel_points (user_id, streamer_id, balance) VALUES (20, 21, 30), (22, 20, 7), (22, 21, 5)').run();
d.prepare("INSERT INTO streams (id, user_id, title) VALUES (1, 20, 'dana live'), (2, 21, 'xena live')").run();
d.prepare("INSERT INTO streams (id, user_id, title) VALUES (1, 20, 'dana live'), (2, 21, 'xena live')").run();
-// yuri chats in dana's stream (goes with the stream); dana chats in xena's (her own line goes); yuri in xena's stays.
-d.prepare("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, 22, 'yuri', 'hey dana'), (2, 20, 'dana', 'hey xena'), (2, 22, 'yuri', 'yo')").run();
d.prepare("INSERT INTO managed_streams (user_id, stream_key) VALUES (20, 'sk_live_secret_dana')").run();
d.prepare("INSERT INTO managed_streams (user_id, stream_key) VALUES (20, 'sk_live_secret_dana')").run();
d.prepare("INSERT INTO api_tokens (user_id, token_hash) VALUES (20, 'hash-secret-dana')").run();
d.prepare("INSERT INTO api_tokens (user_id, token_hash) VALUES (20, 'hash-secret-dana')").run();
d.prepare("INSERT INTO transactions (from_user_id, to_user_id, amount, type, message) VALUES (20, 21, 5, 'donation', 'love you xena'), (22, 20, 3, 'donation', 'for dana')").run();
d.prepare("INSERT INTO transactions (from_user_id, to_user_id, amount, type, message) VALUES (20, 21, 5, 'donation', 'love you xena'), (22, 20, 3, 'donation', 'for dana')").run();
d.prepare("INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, status) VALUES (20, 'paypal', 'PO-1', 'bucks', 500, 'USD', 5, 'completed')").run();
d.prepare("INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, status) VALUES (20, 'paypal', 'PO-1', 'bucks', 500, 'USD', 5, 'completed')").run();
-d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id) VALUES ('timeout', 21, 20)").run();
+// A database migrated before op_003 still holds Live's legacy copies of Chat's tables (a fresh schema creates none):
+// a deletion must erase the person from them while they exist.
+d.exec(`CREATE TABLE IF NOT EXISTS chat_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, username TEXT, message TEXT);
+ CREATE TABLE IF NOT EXISTS pending_ip_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, ip TEXT, message TEXT);`);
+d.prepare("INSERT INTO chat_messages (user_id, username, message) VALUES (20, 'dana', 'hello from dana'), (21, 'xena', 'hi')").run();
+d.prepare("INSERT INTO pending_ip_messages (user_id, ip, message) VALUES (20, '203.0.113.20', 'held line')").run();
const envelope = (type, payload) => {
const envelope = (type, payload) => {
assert.ok(validate(`${type}@1`, payload).valid, JSON.stringify(validate(`${type}@1`, payload).errors));
assert.ok(validate(`${type}@1`, payload).valid, JSON.stringify(validate(`${type}@1`, payload).errors));
@@ -105,12 +109,12 @@ const ulid = () => ids.ulid();
@@ -105,12 +109,12 @@ const ulid = () => ids.ulid();
assert.strictEqual(n('SELECT COUNT(*) AS n FROM channel_points WHERE user_id = 20 OR streamer_id = 20'), 0, 'channel points both ways');
assert.strictEqual(n('SELECT COUNT(*) AS n FROM channel_points WHERE user_id = 20 OR streamer_id = 20'), 0, 'channel points both ways');
assert.strictEqual(n('SELECT COUNT(*) AS n FROM follows'), 1, "others' follows stay");
assert.strictEqual(n('SELECT COUNT(*) AS n FROM follows'), 1, "others' follows stay");
assert.strictEqual(n('SELECT COUNT(*) AS n FROM streams WHERE user_id = 20'), 0);
assert.strictEqual(n('SELECT COUNT(*) AS n FROM streams WHERE user_id = 20'), 0);
- assert.deepStrictEqual(d.prepare('SELECT stream_id, user_id, message FROM chat_messages ORDER BY id').all(), [{ stream_id: 2, user_id: 22, message: 'yo' }],
- 'the stream took its chat with it; her own line elsewhere went; others stay');
assert.strictEqual(n('SELECT COUNT(*) AS n FROM managed_streams WHERE user_id = 20') + n('SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = 20') + n("SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = 20"), 0);
assert.strictEqual(n('SELECT COUNT(*) AS n FROM managed_streams WHERE user_id = 20') + n('SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = 20') + n("SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = 20"), 0);
assert.deepStrictEqual(d.prepare('SELECT from_user_id AS f, message FROM transactions ORDER BY id').all(), [{ f: 20, message: null }, { f: 22, message: 'for dana' }], 'money kept; her own message cleared');
assert.deepStrictEqual(d.prepare('SELECT from_user_id AS f, message FROM transactions ORDER BY id').all(), [{ f: 20, message: null }, { f: 22, message: 'for dana' }], 'money kept; her own message cleared');
assert.strictEqual(n("SELECT COUNT(*) AS n FROM payment_orders WHERE user_id = 20"), 1, 'payment orders kept');
assert.strictEqual(n("SELECT COUNT(*) AS n FROM payment_orders WHERE user_id = 20"), 1, 'payment orders kept');
- assert.strictEqual(n('SELECT COUNT(*) AS n FROM moderation_actions WHERE target_user_id = 20'), 1, 'moderation records kept');
+ assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 20 OR username = \'dana\''), 0, 'her lines are erased from the legacy chat copy');
+ assert.strictEqual(n('SELECT COUNT(*) AS n FROM pending_ip_messages WHERE user_id = 20'), 0, 'and her held line with its IP');
+ assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 21'), 1, "others' lines stay");
const tomb = d.prepare('SELECT username, email, bio, deleted_at FROM users WHERE id = 20').get();
const tomb = d.prepare('SELECT username, email, bio, deleted_at FROM users WHERE id = 20').get();
assert.deepStrictEqual([tomb.username, tomb.email, tomb.bio, !!tomb.deleted_at], ['deleted-20', null, null, true]);
assert.deepStrictEqual([tomb.username, tomb.email, tomb.bio, !!tomb.deleted_at], ['deleted-20', null, null, true]);
d.prepare("INSERT INTO users (username, password_hash) VALUES ('dana', '$sso$')").run();
d.prepare("INSERT INTO users (username, password_hash) VALUES ('dana', '$sso$')").run();
@@ -119,7 +123,7 @@ const ulid = () => ids.ulid();
@@ -119,7 +123,7 @@ const ulid = () => ids.ulid();
assert.strictEqual(conf.path, `/internal/account-deletions/${del.payload.deletion_id}/confirmations`);
assert.strictEqual(conf.path, `/internal/account-deletions/${del.payload.deletion_id}/confirmations`);
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(conf.body.erased.follows >= 2 && conf.body.erased.streams === 1 && conf.body.erased.accounts === 1, JSON.stringify(conf.body.erased));
assert.ok(conf.body.erased.follows >= 2 && conf.body.erased.streams === 1 && conf.body.erased.accounts === 1, JSON.stringify(conf.body.erased));
- assert.ok(conf.body.retained.transactions === 2 && conf.body.retained.payment_orders === 1 && conf.body.retained.moderation_actions === 1, JSON.stringify(conf.body.retained));
+ assert.ok(conf.body.retained.transactions === 2 && conf.body.retained.payment_orders === 1, JSON.stringify(conf.body.retained));
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(sent.length, 3);
assert.strictEqual(sent.length, 3);
@@ -49,7 +49,19 @@ for (const id of [3, 4]) db.ensureChannel(id);
@@ -49,7 +49,19 @@ for (const id of [3, 4]) db.ensureChannel(id);
const chanA = db.getChannelByUserId(3), chanB = db.getChannelByUserId(4);
const chanA = db.getChannelByUserId(3), chanB = db.getChannelByUserId(4);
const streamA = db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid;
const streamA = db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid;
const streamB = db.createStream({ user_id: 4, channel_id: chanB.id, title: 'B', protocol: 'webrtc' }).lastInsertRowid;
const streamB = db.createStream({ user_id: 4, channel_id: chanB.id, title: 'B', protocol: 'webrtc' }).lastInsertRowid;
-const msgB = db.saveChatMessage({ stream_id: streamB, channel_user_id: 4, user_id: 5, username: 'carol', message: 'hello bob' }).lastInsertRowid;
+
+// OpenVibe.Chat owns the chat tables now (Live keeps no copy), so the permission checks that used
+// to read a local row go through the seam: stub Chat's message/relay reads and the moderation
+// write, and keep everything else production code.
+const MSG_B = 900100;
+let RELAY_ROW = null;
+const moderated = [];
+const chatReads = require('../server/chat/chat-reads');
+const delivery = require('../server/chat/chat-delivery');
+chatReads.messageById = async (id) => (Number(id) === MSG_B ? { id: MSG_B, stream_id: Number(streamB), channel_user_id: 4, user_id: 5 } : null);
+chatReads.channelSamples = async (channelUserId, { userId } = {}) => (Number(channelUserId) === 4 && Number(userId) === 5 ? [{ id: 1, username: 'carol', message: 'only in bob' }] : []);
+chatReads.relayUser = async (id) => (RELAY_ROW && Number(id) === RELAY_ROW.id ? RELAY_ROW : null);
+delivery.moderate = async (action, fields) => { moderated.push({ action, fields }); return { ids: fields && fields.id ? [Number(fields.id)] : [] }; };
const express = require('express');
const express = require('express');
const app = express();
const app = express();
@@ -86,13 +98,14 @@ async function check(name, fn) {
@@ -86,13 +98,14 @@ async function check(name, fn) {
await new Promise((r) => server.once('listening', r));
await new Promise((r) => server.once('listening', r));
await check('chat: owning stream A does not let you delete a message in stream B', async () => {
await check('chat: owning stream A does not let you delete a message in stream B', async () => {
- const r = await call('POST', '/api/mod/delete-message', 3, { message_id: msgB, stream_id: streamA });
+ const r = await call('POST', '/api/mod/delete-message', 3, { message_id: MSG_B, stream_id: streamA });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
- assert.strictEqual(db.getChatMessageById(msgB).is_deleted, 0);
+ assert.ok(!moderated.some((m) => m.action === 'delete-message'), 'nothing reached Chat');
});
});
await check('chat: the owner of stream B can delete it', async () => {
await check('chat: the owner of stream B can delete it', async () => {
- const r = await call('POST', '/api/mod/delete-message', 4, { message_id: msgB });
+ const r = await call('POST', '/api/mod/delete-message', 4, { message_id: MSG_B });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
+ assert.ok(moderated.some((m) => m.action === 'delete-message' && m.fields.id === MSG_B), 'Chat is asked to delete it');
});
});
await check('controls: a button id from someone else\'s profile cannot be edited or deleted through yours', async () => {
await check('controls: a button id from someone else\'s profile cannot be edited or deleted through yours', async () => {
@@ -129,10 +142,9 @@ async function check(name, fn) {
@@ -129,10 +142,9 @@ async function check(name, fn) {
await check('relay users: a plain user cannot hide site-wide or remove another channel\'s hide', async () => {
await check('relay users: a plain user cannot hide site-wide or remove another channel\'s hide', async () => {
assert.strictEqual((await call('POST', '/api/mod/relay-user/hide', 5, { platform: 'twitch', external_username: 'x' })).status, 403);
assert.strictEqual((await call('POST', '/api/mod/relay-user/hide', 5, { platform: 'twitch', external_username: 'x' })).status, 403);
- db.hideRelayUser({ channelId: chanB.id, platform: 'twitch', externalUsername: 'troll', action: 'ban', createdBy: 4 });
- const row = raw.prepare('SELECT id FROM hidden_relay_users WHERE channel_id = ?').get(chanB.id);
- assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${row.id}`, 3)).status, 403);
- assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${row.id}`, 4)).status, 200);
+ RELAY_ROW = { id: 900200, channel_id: chanB.id, platform: 'twitch', external_username: 'troll' };
+ assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${RELAY_ROW.id}`, 3)).status, 403);
+ assert.strictEqual((await call('DELETE', `/api/mod/relay-user/${RELAY_ROW.id}`, 4)).status, 200);
});
});
await check('IP approval: owning stream #N does not open channel #N\'s queue', async () => {
await check('IP approval: owning stream #N does not open channel #N\'s queue', async () => {
@@ -167,9 +179,10 @@ async function check(name, fn) {
@@ -167,9 +179,10 @@ async function check(name, fn) {
});
});
await check("AI viewer clone: a streamer cannot copy someone's chat from other channels", async () => {
await check("AI viewer clone: a streamer cannot copy someone's chat from other channels", async () => {
- db.saveChatMessage({ stream_id: streamB, channel_user_id: 4, user_id: 5, username: 'carol', message: 'only in bob' });
- assert.strictEqual(db.getChatSamplesInChannel(4, { userId: 5 }).length, 1);
- assert.strictEqual(db.getChatSamplesInChannel(3, { userId: 5 }).length, 0);
+ // Chat scopes the samples to the asking streamer's channel (stubbed): bob's channel has
+ // carol's line, alice's has none, so alice's clone finds nothing to copy.
+ assert.strictEqual((await chatReads.channelSamples(4, { userId: 5 })).length, 1);
+ assert.strictEqual((await chatReads.channelSamples(3, { userId: 5 })).length, 0);
const r = await call('POST', '/api/ai-viewers/clone', 3, { kind: 'user', ref: 5 });
const r = await call('POST', '/api/ai-viewers/clone', 3, { kind: 'user', ref: 5 });
assert.strictEqual(r.status, 404, r.text);
assert.strictEqual(r.status, 404, r.text);
});
});
@@ -136,9 +136,8 @@ async function check(name, fn) {
@@ -136,9 +136,8 @@ async function check(name, fn) {
assert.strictEqual(billing.payable[SID.bob], 150);
assert.strictEqual(billing.payable[SID.bob], 150);
const row = raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);
const row = raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);
assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);
assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);
- // The chat celebration (display) is still Live's.
- const msg = raw.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation' ORDER BY id DESC LIMIT 1").get();
- assert.ok(msg && /donated 150 Vibes/.test(msg.message));
+ // The chat celebration is OpenVibe.Chat's now: Live sends it the line through the delivery
+ // seam and keeps no copy (test/tips-delivery.test.js covers the payload).
});
});
await check('a browser retry with the same Idempotency-Key is one donation', async () => {
await check('a browser retry with the same Idempotency-Key is one donation', async () => {
@@ -227,10 +227,10 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -227,10 +227,10 @@ const waitFor = async (pred, what, ms = 3000) => {
const db = require('../server/db/database');
const db = require('../server/db/database');
db.initDb();
db.initDb();
- require('../server/chat/dm').ensureTables();
const d = db.getDb();
const d = db.getDb();
- // Live no longer has the staged chat tables (dropped in T3 N+2); the stub Chat below imitates
- // Chat's own copy, so the test keeps local copies to seed and read.
+ // Live no longer has any of OpenVibe.Chat's tables (the staged ones dropped in T3 N+2; the
+ // twelve chat tables dropped by op_003). The stub Chat below imitates Chat's own copy, so the
+ // test keeps local stand-in tables to seed and read.
d.exec(`
d.exec(`
CREATE TABLE IF NOT EXISTS channel_moderators (
CREATE TABLE IF NOT EXISTS channel_moderators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -250,6 +250,38 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -250,6 +250,38 @@ const waitFor = async (pred, what, ms = 3000) => {
user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
channel_owner_id INTEGER
channel_owner_id INTEGER
);
);
+ CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ stream_id INTEGER, channel_user_id INTEGER, user_id INTEGER, anon_id TEXT, username TEXT,
+ message TEXT, message_type TEXT DEFAULT 'chat', is_global INTEGER DEFAULT 0,
+ is_deleted INTEGER DEFAULT 0, source_platform TEXT, metadata TEXT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, auto_delete_at DATETIME
+ );
+ CREATE TABLE IF NOT EXISTS stream_first_chats (
+ chatter_key TEXT NOT NULL, channel_user_id INTEGER NOT NULL,
+ first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (chatter_key, channel_user_id)
+ );
+ CREATE TABLE IF NOT EXISTS hidden_relay_users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER, platform TEXT NOT NULL,
+ external_username TEXT NOT NULL, action TEXT DEFAULT 'hide', reason TEXT, created_by INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS tts_voice_overrides (
+ identity_key TEXT PRIMARY KEY, voice TEXT, pitch INTEGER, speed INTEGER, gap INTEGER DEFAULT 0,
+ set_by INTEGER, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS channel_sounds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_owner_id INTEGER NOT NULL, command TEXT NOT NULL,
+ url TEXT NOT NULL, mime TEXT DEFAULT 'audio/mpeg', duration_seconds REAL DEFAULT 0,
+ created_by INTEGER, created_by_name TEXT DEFAULT '', is_approved INTEGER DEFAULT 1,
+ emote_code TEXT DEFAULT '', media_url TEXT, media_asset_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS pending_ip_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL, stream_id INTEGER,
+ ip_address TEXT NOT NULL, user_id INTEGER, anon_id TEXT, username TEXT, message TEXT NOT NULL,
+ status TEXT DEFAULT 'pending', reviewed_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
`);
`);
const mkUser = (username, role = 'user', extra = {}) => {
const mkUser = (username, role = 'user', extra = {}) => {
const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;
const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;
@@ -448,17 +480,18 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -448,17 +480,18 @@ const waitFor = async (pred, what, ms = 3000) => {
const chatReads = require('../server/chat/chat-reads');
const chatReads = require('../server/chat/chat-reads');
const d2 = require('../server/db/database');
const d2 = require('../server/db/database');
chatReads._reset();
chatReads._reset();
- for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }
- // Chat's copy (the stub reads Live's tables): two lines by the same user, a held IP
+ // Chat's copy (the stub reads the stand-in tables): two lines by the same user, a held IP
// message, a hidden relay user, a TTS override, two pending sounds and a relayed line.
// message, a hidden relay user, a TTS override, two pending sounds and a relayed line.
- const m1 = Number(d2.saveChatMessage({ user_id: viewer, username: 'viewer', message: 'hello', channel_user_id: streamer, message_type: 'chat' }).lastInsertRowid);
- d2.saveChatMessage({ user_id: viewer, username: 'viewer', message: 'again', channel_user_id: streamer, message_type: 'chat' });
+ const chatMsg = (fields) => Number(d.prepare(`INSERT INTO chat_messages (user_id, username, message, channel_user_id, source_platform, message_type)
+ VALUES (@user_id, @username, @message, @channel_user_id, @source_platform, @message_type)`).run({ user_id: null, channel_user_id: null, source_platform: null, message_type: 'chat', ...fields }).lastInsertRowid);
+ const m1 = chatMsg({ user_id: viewer, username: 'viewer', message: 'hello', channel_user_id: streamer });
+ chatMsg({ user_id: viewer, username: 'viewer', message: 'again', channel_user_id: streamer });
d2.run("INSERT INTO pending_ip_messages (channel_id, ip_address, user_id, username, message, status) VALUES (?, '203.0.113.9', ?, 'viewer', 'held', 'pending')", [channel.id, viewer]);
d2.run("INSERT INTO pending_ip_messages (channel_id, ip_address, user_id, username, message, status) VALUES (?, '203.0.113.9', ?, 'viewer', 'held', 'pending')", [channel.id, viewer]);
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)", [channel.id, streamer]);
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)", [channel.id, streamer]);
d2.run("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:viewer', 'en+f3', 99, 200, 0, ?)", [admin]);
d2.run("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:viewer', 'en+f3', 99, 200, 0, ?)", [admin]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/a.mp3', ?), (?, 'beep', '/sounds/b.mp3', ?)", [streamer, streamer, streamer, streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/a.mp3', ?), (?, 'beep', '/sounds/b.mp3', ?)", [streamer, streamer, streamer, streamer]);
- d2.saveChatMessage({ username: '[Twitch] alice', message: 'hi from twitch', source_platform: 'twitch', channel_user_id: streamer, message_type: 'chat' });
+ chatMsg({ username: '[Twitch] alice', message: 'hi from twitch', source_platform: 'twitch', channel_user_id: streamer });
// (2)(3) A peek answers the last good value after its TTL passed, and a failure answers
// (2)(3) A peek answers the last good value after its TTL passed, and a failure answers
// that value too — a failure never overwrites it with null.
// that value too — a failure never overwrites it with null.
@@ -627,15 +660,16 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -627,15 +660,16 @@ const waitFor = async (pred, what, ms = 3000) => {
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
- // (5) The home-stats peek's local (rollback) answer is a messages-only count.
+ // (5) The home-stats peek outside chat mode: a messages-only zero (Live keeps no
+ // chat_messages since the drop, so there is nothing local to count).
process.env.CHAT_AUTHORITY = '';
process.env.CHAT_AUTHORITY = '';
try {
try {
chatReads._reset();
chatReads._reset();
const peeked = chatReads.siteStatsPeek();
const peeked = chatReads.siteStatsPeek();
assert.strictEqual(peeked.chatters, undefined, 'the peek does not run the distinct-chatter scan');
assert.strictEqual(peeked.chatters, undefined, 'the peek does not run the distinct-chatter scan');
- assert.ok(peeked.messages >= 2, 'the peek still counts the messages');
+ assert.strictEqual(peeked.messages, 0, 'no local messages to count');
const full = await chatReads.siteStats();
const full = await chatReads.siteStats();
- assert.ok(full.chatters >= 1, 'the full stats read still counts chatters');
+ assert.strictEqual(full.messages, 0, 'the full local read is zero too');
} finally {
} finally {
process.env.CHAT_AUTHORITY = 'chat';
process.env.CHAT_AUTHORITY = 'chat';
chatReads._reset();
chatReads._reset();
@@ -735,8 +769,8 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -735,8 +769,8 @@ const waitFor = async (pred, what, ms = 3000) => {
chatReads._reset();
chatReads._reset();
}
}
- // (3) Live's own hidden copy is frozen and dead weight in chat mode: a stale local hide
- // that Chat's list does not carry must not hide anyone (the mirror no longer keeps it in step).
+ // (3) A stale local row is dead weight in chat mode: a hide Chat's list does not carry
+ // must not hide anyone (the stand-in row is never read).
{
{
const chatClient = require('../server/chat/chat-client');
const chatClient = require('../server/chat/chat-client');
const realReadRelayUsers = chatClient.readRelayUsers;
const realReadRelayUsers = chatClient.readRelayUsers;
@@ -746,10 +780,8 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -746,10 +780,8 @@ const waitFor = async (pred, what, ms = 3000) => {
chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothing
chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothing
chatReads._reset();
chatReads._reset();
try {
try {
- assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'no local row yet');
+ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open');
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);
- assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own row is there");
- assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open, never Live\'s row');
for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);
for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);
assert.ok(chatReads._size() > 0, 'the background read warmed the cache');
assert.ok(chatReads._size() > 0, 'the background read warmed the cache');
assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");
assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");
@@ -805,6 +837,7 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -805,6 +837,7 @@ const waitFor = async (pred, what, ms = 3000) => {
[viewer, atDay(2), viewer, atDay(2), mod, atDay(2)]);
[viewer, atDay(2), viewer, atDay(2), mod, atDay(2)]);
d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, timestamp) VALUES (?, 'viewer', 'd', 'chat', ?), (?, 'moddy', 'e', 'chat', ?)",
d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, timestamp) VALUES (?, 'viewer', 'd', 'chat', ?), (?, 'moddy', 'e', 'chat', ?)",
[viewer, atDay(1), mod, atDay(1)]);
[viewer, atDay(1), mod, atDay(1)]);
+ await chatReads.homeSeries('messages', 7); // warm Chat's answer; the peek cannot await
const series = chatReads.homeSeriesPeek('messages', 7);
const series = chatReads.homeSeriesPeek('messages', 7);
assert.ok(series && Array.isArray(series.points), 'the chat series peek answers in chat mode');
assert.ok(series && Array.isArray(series.points), 'the chat series peek answers in chat mode');
assert.strictEqual(series.days, 7);
assert.strictEqual(series.days, 7);
@@ -815,6 +848,7 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -815,6 +848,7 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.strictEqual(series.total, 5);
assert.strictEqual(series.total, 5);
assert.strictEqual(series.prev_total, 0, 'the window before the 7 days is empty');
assert.strictEqual(series.prev_total, 0, 'the window before the 7 days is empty');
assert.strictEqual(series.before, 0, 'nothing before the window');
assert.strictEqual(series.before, 0, 'nothing before the window');
+ await chatReads.homeSeries('active', 7); // warm Chat's answer
const activeSeries = chatReads.homeSeriesPeek('active', 7);
const activeSeries = chatReads.homeSeriesPeek('active', 7);
assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');
assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');
assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');
assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');
@@ -823,12 +857,10 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -823,12 +857,10 @@ const waitFor = async (pred, what, ms = 3000) => {
chatReads.homeSeriesPeek = () => ({ metric: 'messages', sentinel: true });
chatReads.homeSeriesPeek = () => ({ metric: 'messages', sentinel: true });
try { assert.strictEqual(d2.getHomeStatSeries('messages', 7).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode'); }
try { assert.strictEqual(d2.getHomeStatSeries('messages', 7).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode'); }
finally { chatReads.homeSeriesPeek = realSeriesPeek; }
finally { chatReads.homeSeriesPeek = realSeriesPeek; }
- // A cold cache during a Chat outage answers Live's own table, not zeros.
+ // A cold cache during a Chat outage answers null (Live keeps no chat series to fall back to).
chatReads._reset();
chatReads._reset();
readState.down = true;
readState.down = true;
- const fallbackSeries = chatReads.homeSeriesPeek('messages', 7);
- assert.ok(fallbackSeries && fallbackSeries.points.length === 7, 'a cold + down peek answers Live\'s own series');
- assert.strictEqual(fallbackSeries.total, 5, 'the fallback is Live\'s own series, not zeros');
+ assert.strictEqual(chatReads.homeSeriesPeek('messages', 7), null, 'a cold + down peek answers null');
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
@@ -866,9 +898,8 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -866,9 +898,8 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');
assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');
chatReads._reset();
chatReads._reset();
d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
- assert.strictEqual(d2.isFirstChatInChannel(`user:${viewer}`, streamer), true, 'Live\'s frozen table would say first');
readState.down = true;
readState.down = true;
- assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first, never Live\'s frozen table');
+ assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first (Live keeps no first-chat table)');
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
'use strict';
'use strict';
/**
/**
- * Live's chat history reads now come from OpenVibe.Chat (roadmap T3 J4b: Read Live's chat stats,
+ * Live's chat history reads come from OpenVibe.Chat (roadmap T3 J4b: Read Live's chat stats,
* queues and history from Chat). Live's own history store — the page/delta reads over its copy of
* queues and history from Chat). Live's own history store — the page/delta reads over its copy of
- * chat_messages — is gone with the read mirror, so nothing keeps a second answer to "what happened
- * in this room?". These checks guard that the dead module stays deleted, and that the replacement
- * (server/chat/chat-reads.js) answers history from Live's own tables while Live runs chat itself
- * (the rollback / dev path) with the same rules: deleted and auto-expired rows never come back.
+ * chat_messages — went with the read mirror, and the table itself is dropped (op_003), so nothing
+ * keeps a second answer to "what happened in this room?". These checks guard that the dead module
+ * stays deleted, and that outside chat mode (dev, drills — Live runs no chat server either way) the
+ * read seam answers empty/null rather than any local value.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -16,45 +16,20 @@ const ROOT = path.join(__dirname, '..');
@@ -16,45 +16,20 @@ const ROOT = path.join(__dirname, '..');
assert.ok(!fs.existsSync(path.join(ROOT, 'server', 'chat', 'history-store.js')), 'history-store.js is deleted — history is Chat\'s now');
assert.ok(!fs.existsSync(path.join(ROOT, 'server', 'chat', 'history-store.js')), 'history-store.js is deleted — history is Chat\'s now');
process.env.DB_PATH = path.join(os.tmpdir(), `ov-chat-history-${Date.now()}.db`);
process.env.DB_PATH = path.join(os.tmpdir(), `ov-chat-history-${Date.now()}.db`);
-delete process.env.CHAT_AUTHORITY; // Live runs chat itself: chat-reads answers from Live's tables
+delete process.env.CHAT_AUTHORITY; // not chat mode: the seam answers its empty local answers
const db = require('../server/db/database');
const db = require('../server/db/database');
db.initDb();
db.initDb();
const reads = require('../server/chat/chat-reads');
const reads = require('../server/chat/chat-reads');
-const u = db.run(`INSERT INTO users (username, password_hash, email) VALUES ('alice', 'x', '[email protected]')`).lastInsertRowid;
-const bob = db.run(`INSERT INTO users (username, password_hash, email) VALUES ('bob', 'x', '[email protected]')`).lastInsertRowid;
-
-const ids = [];
-for (let i = 1; i <= 12; i++) {
- const r = db.saveChatMessage({ user_id: u, username: 'alice', message: `m${i}`, stream_id: null, is_global: 1, message_type: 'chat' });
- ids.push(Number(r.lastInsertRowid));
-}
-// One deleted and one auto-expired row must never come back through either read.
-db.run(`UPDATE chat_messages SET is_deleted = 1 WHERE id = ?`, [ids[5]]);
-db.run(`UPDATE chat_messages SET auto_delete_at = datetime('now', '-1 minute') WHERE id = ?`, [ids[6]]);
-// A channel room row (channel_user_id set) belongs to its channel's history.
-const chanId = Number(db.saveChatMessage({ user_id: bob, username: 'bob', message: 'channel only', stream_id: null, channel_user_id: bob, message_type: 'chat' }).lastInsertRowid);
-
(async () => {
(async () => {
- // A user's site-wide history (deleted and auto-expired rows never come back). The rows share a
- // timestamp here, so compare as a set.
- const hist = await reads.userHistory(u, { limit: 50 });
- assert.deepStrictEqual(hist.messages.map((m) => m.message).sort(), ['m1', 'm10', 'm11', 'm12', 'm2', 'm3', 'm4', 'm5', 'm8', 'm9'], 'user history, no deleted/expired rows');
- assert.ok(hist.total >= hist.messages.length, 'a total is reported for paging');
-
- // A channel's recent lines, oldest→newest — what an AI viewer's prompt sees.
- const ch = await reads.channelMessages(bob, 10);
- assert.deepEqual(ch.map((m) => m.message), ['channel only'], 'only the channel room, oldest → newest');
- assert.strictEqual(ch[ch.length - 1].id, chanId);
-
- // A chat search by user id (the admin console), same rows.
- const search = await reads.searchMessages({ userId: u, limit: 50 });
- assert.strictEqual(search.messages.length, hist.messages.length, 'search by user id matches the history');
-
- // One message by id (the moderation delete path).
- const one = await reads.messageById(ids[0]);
- assert.strictEqual(one.message, 'm1');
- assert.strictEqual(await reads.messageById(10 ** 9), null, 'an unknown id is null');
-
- console.log('chat-history-store: ok — Live\'s store is deleted; chat-reads answers history in local mode');
+ assert.deepStrictEqual(await reads.userHistory(1, { limit: 50 }), { messages: [], total: 0 }, 'no local user history');
+ assert.deepStrictEqual(await reads.channelMessages(1, 10), [], 'no local channel page');
+ assert.deepStrictEqual(await reads.searchMessages({ userId: 1, limit: 50 }), { messages: [], total: 0 }, 'no local search');
+ assert.strictEqual(await reads.messageById(1), null, 'no local message by id');
+ // The dropped tables are not recreated by a boot.
+ for (const t of ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
+ 'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions']) {
+ assert.ok(!db.getDb().prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t), `${t} must not exist`);
+ }
+ console.log('chat-history-store: ok — Live\'s store is deleted; the read seam answers empty outside chat mode');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -45,13 +45,16 @@ const network = http.createServer((req, res) => {
@@ -45,13 +45,16 @@ const network = http.createServer((req, res) => {
});
});
});
});
-// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its first-chat
-// and hidden-relay-user reads answer from Live's own tables (emulating Chat's copy).
+// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its
+// first-chat, hidden-relay-user and message reads answer from this stub's own state (Live keeps no
+// copy of any of those tables — OpenVibe.Chat owns them).
const calls = [];
const calls = [];
const bridgeCalls = [];
const bridgeCalls = [];
const fail = {};
const fail = {};
let nextId = 5000;
let nextId = 5000;
let firstChatReads = 0;
let firstChatReads = 0;
+// Chat's own copy: a chatter's first line is recorded when the message lands, hides are rows.
+const chatState = { firstChats: new Set(), relayHidden: [], messages: new Map() };
const chat = http.createServer((req, res) => {
const chat = http.createServer((req, res) => {
let raw = '';
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('data', (c) => { raw += c; });
@@ -65,29 +68,29 @@ const chat = http.createServer((req, res) => {
@@ -65,29 +68,29 @@ const chat = http.createServer((req, res) => {
if (String(req.url).startsWith('/internal/chat/first-chat')) {
if (String(req.url).startsWith('/internal/chat/first-chat')) {
firstChatReads++;
firstChatReads++;
const q = new URL(req.url, 'http://x').searchParams;
const q = new URL(req.url, 'http://x').searchParams;
- const d3 = require('../server/db/database');
- const row = d3.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
- return res.end(JSON.stringify({ ok: true, first: !row }));
+ return res.end(JSON.stringify({ ok: true, first: !chatState.firstChats.has(String(q.get('identity'))) }));
}
}
if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {
if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {
const q = new URL(req.url, 'http://x').searchParams;
const q = new URL(req.url, 'http://x').searchParams;
- const rows = require('../server/db/database').all('SELECT * FROM hidden_relay_users WHERE channel_id = ? OR channel_id IS NULL LIMIT ?', [Number(q.get('channel_id')), Number(q.get('limit') || 100)]);
+ const channelId = Number(q.get('channel_id'));
+ const rows = chatState.relayHidden.filter((r) => r.channel_id === channelId || r.channel_id == null);
return res.end(JSON.stringify({ ok: true, relay_users: rows }));
return res.end(JSON.stringify({ ok: true, relay_users: rows }));
}
}
const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);
const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);
if (!m) { res.statusCode = 404; return res.end('{}'); }
if (!m) { res.statusCode = 404; return res.end('{}'); }
const family = m[1];
const family = m[1];
if (family === 'presence') return res.end(JSON.stringify({ total: 4, streams: { 1: 2 }, slow_mode: {}, users: [{ user_id: 99, ip: '198.51.100.9', stream_id: 1 }], anons: [] }));
if (family === 'presence') return res.end(JSON.stringify({ total: 4, streams: { 1: 2 }, slow_mode: {}, users: [{ user_id: 99, ip: '198.51.100.9', stream_id: 1 }], anons: [] }));
- // Chat's message READ (Chat holds the rows now; Live reads them through it): answer the
- // row Live's own copy carries, so the moderation path's permission check sees it.
+ // Chat's message READ (Chat holds the rows; Live reads them through it): answer from the
+ // stub's own copy, so the moderation path's permission check sees the row.
if (family === 'messages' && req.method === 'GET') {
if (family === 'messages' && req.method === 'GET') {
- const d3 = require('../server/db/database');
const id = Number(new URL(req.url, 'http://x').searchParams.get('id'));
const id = Number(new URL(req.url, 'http://x').searchParams.get('id'));
- const row = d3.getChatMessageById(id);
+ const row = chatState.messages.get(id) || null;
return res.end(JSON.stringify({ ok: true, messages: row ? [row] : [], max_id: row ? id : null }));
return res.end(JSON.stringify({ ok: true, messages: row ? [row] : [], max_id: row ? id : null }));
}
}
const body = JSON.parse(raw || '{}');
const body = JSON.parse(raw || '{}');
calls.push({ family, auth, body });
calls.push({ family, auth, body });
+ // Chat records a chatter's first line when the message lands.
+ if (family === 'messages' && body.username) chatState.firstChats.add(`ext:${body.username}`);
const f = fail[family];
const f = fail[family];
if (f && f.times > 0) { f.times--; res.statusCode = f.status; return res.end(JSON.stringify({ error: 'down' })); }
if (f && f.times > 0) { f.times--; res.statusCode = f.status; return res.end(JSON.stringify({ error: 'down' })); }
if (family === 'messages') return res.end(JSON.stringify({ ok: true, id: ++nextId }));
if (family === 'messages') return res.end(JSON.stringify({ ok: true, id: ++nextId }));
@@ -166,7 +169,7 @@ function assertSigned(c, what) {
@@ -166,7 +169,7 @@ function assertSigned(c, what) {
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');
assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');
assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');
assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");
assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");
- assert.ok(!d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live writes no first-chat copy of its own in chat mode');
+ assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stream_first_chats'").get(), 'Live has no first-chat copy of its own');
const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;
const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});
await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');
await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');
@@ -176,15 +179,15 @@ function assertSigned(c, what) {
@@ -176,15 +179,15 @@ function assertSigned(c, what) {
// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.
// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.
const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;
const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;
const chatReads = require('../server/chat/chat-reads');
const chatReads = require('../server/chat/chat-reads');
- d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);
- chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes the row directly
+ chatState.relayHidden.push({ id: 1, channel_id: channel.id, platform: 'twitch', external_username: 'alice', action: 'hide' });
+ chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes Chat's row directly
await waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");
await waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");
const beforeHidden = relayLines();
const beforeHidden = relayLines();
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});
await sleep(80);
await sleep(80);
assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');
assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');
assert.strictEqual(relayLines('should not relay'), 0);
assert.strictEqual(relayLines('should not relay'), 0);
- d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();
+ chatState.relayHidden.length = 0;
chatReads.invalidate('hru:'); // as the unhide route does
chatReads.invalidate('hru:'); // as the unhide route does
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});
assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');
assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');
@@ -213,12 +216,12 @@ function assertSigned(c, what) {
@@ -213,12 +216,12 @@ function assertSigned(c, what) {
const r = await fetch(`http://127.0.0.1:${port}/api/mod${p}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` }, body: JSON.stringify(body) });
const r = await fetch(`http://127.0.0.1:${port}/api/mod${p}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session}` }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => null) };
return { status: r.status, body: await r.json().catch(() => null) };
};
};
- d.prepare("INSERT INTO chat_messages (id, stream_id, channel_user_id, user_id, username, message) VALUES (900010, ?, ?, ?, 'VIEWER', 'bad')").run(streamId, streamer, viewer);
+ chatState.messages.set(900010, { id: 900010, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'bad' });
assert.strictEqual((await mod('/delete-message', { message_id: 900010 })).status, 200);
assert.strictEqual((await mod('/delete-message', { message_id: 900010 })).status, 200);
const del = find('moderation', (b) => b.action === 'delete-message');
const del = find('moderation', (b) => b.action === 'delete-message');
assertSigned(del, 'message delete');
assertSigned(del, 'message delete');
assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);
assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);
- assert.strictEqual(db.getChatMessageById(900010).is_deleted, 0, "Live's own copy is untouched: Chat owns the delete (the read mirror is retired)");
+ assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_messages'").get(), 'Live has no chat table to update: Chat owns the delete');
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');
const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });
const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });
assert.ok(ban.status < 300, `ban answered ${ban.status}`);
assert.ok(ban.status < 300, `ban answered ${ban.status}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+
+// The twelve OpenVibe.Chat-owned chat tables (roadmap T3, final step). Live's readers and writers
+// are gone (#28–#32 and the release that carries this file), it no longer creates them, and
+// op_003_drop_chat_tables (scripts/chat-tables-drop.js) drops them. The drop is an operator
+// contract step, not a boot migration: the release before this one still runs SQL over them
+// (test/n-1.test.js), so it may only run once that release is out of rollback range (ADR-028).
+// This guard: nothing under server/ names any of the twelve (the drop migration's own statements
+// are the one exception), a freshly migrated database has none, and the operator migration drops
+// legacy copies and is idempotent.
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const ROOT = path.join(__dirname, '..');
+
+const TABLES = [
+ 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
+ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',
+ 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
+];
+const T = TABLES.join('|');
+
+const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
+const WRITE_SQL = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');
+const SCHEMA_SQL = new RegExp(`\\b(CREATE\\s+(?:TABLE|INDEX|TRIGGER)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?|ALTER\\s+TABLE\\s+|PRAGMA\\s+(?:table_info|table_xinfo|index_list|foreign_key_list)\\s*\\(\\s*[\`"']?)(${T})\\b`, 'i');
+// The operator drop (server/db/migrations.js) is the one place allowed to name them.
+const DROP_STMT = new RegExp(`^\\s*DROP\\s+(?:TABLE|INDEX|TRIGGER)\\s+IF\\s+EXISTS\\s+(?:${T})\\s*;\\s*$`, 'i');
+
+function walk(dir, out = []) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); }
+ else if (e.name.endsWith('.js') || e.name.endsWith('.sql')) out.push(p);
+ }
+ return out;
+}
+const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
+
+/** Every read matching one of `regexes` in a file's text → [{ line, text }]. */
+function findReads(src, regexes) {
+ const lines = src.split('\n');
+ const hits = [];
+ for (const re of regexes) {
+ re.lastIndex = 0;
+ let m;
+ while ((m = re.exec(src))) {
+ const startLine = src.slice(0, m.index).split('\n').length;
+ const endLine = src.slice(0, m.index + m[0].length - 1).split('\n').length;
+ if (isCommentLine(lines[startLine - 1]) || isCommentLine(lines[endLine - 1])) continue;
+ hits.push({ line: endLine, text: lines[endLine - 1].trim() });
+ }
+ }
+ return hits;
+}
+
+// 1. No server/ code names any of the twelve (the drop migration's DROP lines excepted).
+const offenders = [];
+for (const file of walk(path.join(ROOT, 'server'))) {
+ const rel = path.relative(ROOT, file);
+ const src = fs.readFileSync(file, 'utf8');
+ const lines = src.split('\n');
+ if (rel !== path.join('server', 'db', 'migrations.js')) {
+ for (const hit of findReads(src, [READ_SQL])) offenders.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
+ }
+ lines.forEach((line, i) => {
+ if (isCommentLine(line)) return;
+ if (rel === path.join('server', 'db', 'migrations.js') && DROP_STMT.test(line)) return;
+ if (WRITE_SQL.test(line) || SCHEMA_SQL.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
+ });
+}
+assert.deepStrictEqual(offenders, [], `server/ still names OpenVibe.Chat's chat tables (read and write them through Chat's API; the operator drop is the only exception):\n${offenders.join('\n')}`);
+
+// 2. A database created by the schema has none of them.
+const fresh = new Database(':memory:');
+fresh.exec(fs.readFileSync(path.join(ROOT, 'server', 'db', 'schema.sql'), 'utf8'));
+for (const t of TABLES) {
+ assert.ok(!fresh.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t), `fresh schema must not create ${t}`);
+}
+fresh.close();
+
+// 3. A boot neither creates nor drops them (the drop is the operator step), and op_003 drops them.
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-tables-dropped-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+const legacy = new Database(process.env.DB_PATH);
+for (const t of TABLES) legacy.exec(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY)`);
+legacy.close();
+
+const db = require('../server/db/database');
+db.initDb();
+const d = db.getDb();
+const exists = (t) => !!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
+for (const t of TABLES) assert.ok(exists(t), `${t} must survive a boot: dropping it is the operator step, not a boot migration`);
+assert.ok(!d.prepare("SELECT 1 FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'").get(), 'op_003 is not a boot migration');
+
+const migrations = require('../server/db/migrations');
+assert.ok(!migrations.MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is not in the boot list');
+assert.ok(migrations.OPERATOR_MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is an operator migration');
+assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'applied', 'the operator drop applies');
+for (const t of TABLES) assert.ok(!exists(t), `${t} must be gone after op_003`);
+assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'already', 'the operator drop is idempotent');
+db.close();
+fs.rmSync(tmp, { recursive: true, force: true });
+
+console.log(`chat tables dropped: no server/ code names the ${TABLES.length}; fresh schema none; op_003 drops legacy copies (idempotent)`);
+process.exit(0);
deleted file mode 100644
deleted file mode 100644
@@ -1,41 +0,0 @@
@@ -1,41 +0,0 @@
-'use strict';
-// Deploy notices fold into one rolling row, never repeat a commit, and stay silent without new code.
-const assert = require('assert');
-const dn = require('../server/chat/deploy-notice');
-
-const rows = []; const settings = {};
-const db = {
- getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
- get: () => rows.filter(r => !r.is_deleted).slice(-1)[0] || undefined, // newest row of ANY room
- run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
- saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
-};
-const c = (n) => ({ hash: String(n).repeat(40).slice(0, 40).replace(/[^0-9a-f]/g, 'a'), short: 'c' + n, subject: 'change ' + n, date: new Date().toISOString() });
-
-const first = dn.persist(db, [c(1)]);
-assert.equal(rows.length, 1); assert.equal(first.meta.deploys, 1);
-const second = dn.persist(db, [c(2), c(1)]); // overlap with what is already there
-assert.equal(rows.length, 1, 'a second deploy updates the same row');
-assert.equal(second.id, first.id);
-assert.deepEqual(second.meta.commits.map(x => x.short), ['c2', 'c1'], 'each commit once, newest first');
-assert.equal(second.meta.deploys, 2);
-assert.ok(/^🚀 2 updates shipped/.test(rows[0].message));
-
-rows.push({ id: 2, is_global: 1, is_deleted: 0, message_type: 'chat', message: 'hello', metadata: null }); // someone spoke
-const third = dn.persist(db, [c(3)]);
-assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
-assert.equal(third.meta.deploys, 1);
-
-// A message in a STREAM room (not global, but shown in the global feed) also ends the card: folding
-// past it would give the card a time range running past the message shown under it.
-rows.push({ id: rows.length + 1, is_global: 0, is_deleted: 0, message_type: 'chat', message: 'hello brother', metadata: null });
-const fourth = dn.persist(db, [c(4)]);
-assert.equal(rows.length, 5, 'a stream-room message starts a new card too');
-assert.equal(fourth.meta.deploys, 1);
-
-const old = JSON.parse(rows[4].metadata); old.first_at = new Date(Date.now() - 4 * 3600 * 1000).toISOString(); rows[4].metadata = JSON.stringify(old);
-dn.persist(db, [c(5)]);
-assert.equal(rows.length, 6, 'a notice older than the 3-hour fold window is not reopened');
-
-assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
-console.log('deploy notice: all checks passed');
deleted file mode 100644
deleted file mode 100644
@@ -1,54 +0,0 @@
@@ -1,54 +0,0 @@
-'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');
-const fs = require('fs');
-
-const tempDbPath = path.join(os.tmpdir(), `openvibelive-dm-leak-${Date.now()}.db`);
-process.env.DB_PATH = tempDbPath;
-
-const db = require('../server/db/database');
-const dm = require('../server/chat/dm');
-
-async function run() {
- try {
- db.initDb();
- dm.ensureTables();
-
- const userA = db.createUser({ username: 'accountA', email: null, password_hash: '!test', display_name: 'Account A', stream_key: 'streamA' }).lastInsertRowid;
- const userB = db.createUser({ username: 'accountB', email: null, password_hash: '!test', display_name: 'Account B', stream_key: 'streamB' }).lastInsertRowid;
- const userC = db.createUser({ username: 'accountC', email: null, password_hash: '!test', display_name: 'Account C', stream_key: 'streamC' }).lastInsertRowid;
- const userD = db.createUser({ username: 'accountD', email: null, password_hash: '!test', display_name: 'Account D', stream_key: 'streamD' }).lastInsertRowid;
-
- const convId = dm.createConversation(userA, [userA, userB, userC], 'Test Group');
- const participants = dm.getParticipants(convId).map(u => u.id).sort((a, b) => a - b);
- assert.deepStrictEqual(participants, [userA, userB, userC]);
- assert(dm.isParticipant(convId, userA), 'userA should be participant');
- assert(dm.isParticipant(convId, userB), 'userB should be participant');
- assert(dm.isParticipant(convId, userC), 'userC should be participant');
- assert(!dm.isParticipant(convId, userD), 'userD should not be a participant');
-
- // 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);
-
- console.log('✅ DM helpers (participants, blocks) regression test passed');
- } finally {
- try { db.close(); } catch {};
- try { fs.unlinkSync(tempDbPath); } catch {};
- }
-}
-
-run().catch((err) => {
- console.error('DM helpers test failed:', err);
- process.exit(1);
-});
@@ -133,13 +133,12 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
@@ -133,13 +133,12 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
db.run("INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'Restored stream', 'webrtc', 1, 3, datetime('now'), datetime('now'))", [star]);
db.run("INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'Restored stream', 'webrtc', 1, 3, datetime('now'), datetime('now'))", [star]);
db.run("INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at) VALUES (?, 'Old stream', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))", [star]);
db.run("INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at) VALUES (?, 'Old stream', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))", [star]);
db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, star]);
db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, star]);
- db.run("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, ?, 'drillfan', 'hello from the backup')", [fan]);
db.initDb(); // production booted since these rows were written (its backfills already ran on them)
db.initDb(); // production booted since these rows were written (its backfills already ran on them)
db.close();
db.close();
`], { cwd: REPO, encoding: 'utf8', env: { ...process.env, DB_PATH, DATA_DIR, NODE_ENV: 'test' } });
`], { cwd: REPO, encoding: 'utf8', env: { ...process.env, DB_PATH, DATA_DIR, NODE_ENV: 'test' } });
assert.strictEqual(seed.status, 0, `seeding the copy failed:\n${seed.stdout}\n${seed.stderr}`);
assert.strictEqual(seed.status, 0, `seeding the copy failed:\n${seed.stdout}\n${seed.stderr}`);
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
- const COUNTED = ['users', 'channels', 'managed_streams', 'streams', 'follows', 'chat_messages'];
+ const COUNTED = ['users', 'channels', 'managed_streams', 'streams', 'follows'];
const counts = () => { const d = new Database(DB_PATH, { readonly: true }); try { return Object.fromEntries(COUNTED.map((t) => [t, d.prepare(`SELECT count(*) AS n FROM ${t}`).get().n])); } finally { d.close(); } };
const counts = () => { const d = new Database(DB_PATH, { readonly: true }); try { return Object.fromEntries(COUNTED.map((t) => [t, d.prepare(`SELECT count(*) AS n FROM ${t}`).get().n])); } finally { d.close(); } };
const countsBefore = counts();
const countsBefore = counts();
@@ -248,7 +247,7 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
@@ -248,7 +247,7 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
}
}
const stats = await request('GET', '/api/home/stats-live');
const stats = await request('GET', '/api/home/stats-live');
assert.strictEqual(stats.json.liveNow === undefined ? 1 : stats.json.liveNow, 1);
assert.strictEqual(stats.json.liveNow === undefined ? 1 : stats.json.liveNow, 1);
- assert.strictEqual(stats.json.chatMessages, 1);
+ assert.strictEqual(stats.json.chatMessages, 0, 'no local chat count: OpenVibe.Chat owns the chat table');
});
});
await check('a route that asks Media answers as if Media were down: the call is refused, not sent', async () => {
await check('a route that asks Media answers as if Media were down: the call is refused, not sent', async () => {
const r = await request('GET', '/api/streams/channel/drillstar');
const r = await request('GET', '/api/streams/channel/drillstar');
@@ -1,12 +1,8 @@
@@ -1,12 +1,8 @@
{
{
"_about": "Destructive schema statements in server/ that are known and accounted for (roadmap WS-P task 12, expand/migrate/contract). A new DROP TABLE, DROP COLUMN, RENAME COLUMN or RENAME TO of a real table fails test/destructive-migrations.test.js until it is listed here with its contract step: the dated plan entry (section 10) that runs it after a release where nothing reads the old shape, with a backup. Temporary tables (_x, x_new, x_old, x_tmp) are not counted.",
"_about": "Destructive schema statements in server/ that are known and accounted for (roadmap WS-P task 12, expand/migrate/contract). A new DROP TABLE, DROP COLUMN, RENAME COLUMN or RENAME TO of a real table fails test/destructive-migrations.test.js until it is listed here with its contract step: the dated plan entry (section 10) that runs it after a release where nothing reads the old shape, with a backup. Temporary tables (_x, x_new, x_old, x_tmp) are not counted.",
"allowed": [
"allowed": [
- { "file": "server/db/database.js", "statement": "DROP TABLE CHAT_MESSAGES", "count": 2, "contract": "pre-ADR table rebuild (copy every column into chat_messages_new, drop, rename); guarded, one-time" },
- { "file": "server/db/database.js", "statement": "RENAME TO CHAT_MESSAGES", "count": 2, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "DROP TABLE ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "RENAME TO ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "RENAME TO ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "the same rebuild" },
- { "file": "server/db/database.js", "statement": "DROP TABLE CHANNEL_SOUNDS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
- { "file": "server/db/database.js", "statement": "RENAME TO CHANNEL_SOUNDS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE STREAM_CONTROLS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "DROP TABLE STREAM_CONTROLS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "RENAME TO STREAM_CONTROLS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "RENAME TO STREAM_CONTROLS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_MODERATORS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_MODERATORS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
@@ -17,6 +13,18 @@
@@ -17,6 +13,18 @@
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_STAGED_OUTBOX", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the staged-table relay was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_STAGED_OUTBOX", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the staged-table relay was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS EMOTES", "count": 1, "contract": "roadmap T3 N+3 (006_drop_emotes): Chat owns emotes; N-1 fb3957f does not read its Live copy; backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS EMOTES", "count": 1, "contract": "roadmap T3 N+3 (006_drop_emotes): Chat owns emotes; N-1 fb3957f does not read its Live copy; backups kept operator-side" },
- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_BRIDGE_OUTBOX", "count": 1, "contract": "roadmap T3 J2 (op_002_drop_chat_bridge_outbox, operator-run via scripts/chat-bridge-outbox-drop.js): Live's chat bridge was deleted in this release; N-1 still writes the table, so the drop waits until N-1 is out of rollback range, delivers unacknowledged writes to Chat first and refuses while any remain; backups kept operator-side" }
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_BRIDGE_OUTBOX", "count": 1, "contract": "roadmap T3 J2 (op_002_drop_chat_bridge_outbox, operator-run via scripts/chat-bridge-outbox-drop.js): Live's chat bridge was deleted in this release; N-1 still writes the table, so the drop waits until N-1 is out of rollback range, delivers unacknowledged writes to Chat first and refuses while any remain; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_PARTICIPANTS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_CONVERSATIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_BLOCKS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS TTS_VOICE_OVERRIDES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_SOUNDS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS HIDDEN_RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS PENDING_IP_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS STREAM_FIRST_CHATS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
+ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS MODERATION_ACTIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" }
]
]
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,91 +0,0 @@
@@ -1,91 +0,0 @@
-'use strict';
-// Live's staff actions reach OpenVibe.Network's moderation audit log (ADR-022, WS-D task 1, Contracts
-// 0.46.0): logModerationAction writes live.moderation.action to the outbox in the same transaction as the
-// row (valid against the contract, actor = the staff member's subject); tidying one's own messages,
-// configuring one's own channel and acting on oneself stay local; with the outbox off the row is still
-// written.
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modevents-'));
-process.env.DB_PATH = path.join(tmp, 'live.db');
-const quiet = console.log;
-console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
-console.warn = () => {};
-
-const published = [];
-const stub = http.createServer((req, res) => {
- let body = '';
- req.on('data', (c) => { body += c; });
- req.on('end', () => {
- res.setHeader('Content-Type', 'application/json');
- if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));
- if (req.url === '/api/v1/events' && req.method === 'POST') {
- const parsed = JSON.parse(body);
- const list = parsed.events || [parsed];
- const { validate } = require('openvibe-contracts');
- const bad = list.find((e) => !validate('events.event-envelope@1', e).valid);
- if (bad) { res.statusCode = 422; return res.end(JSON.stringify({ code: 'events.invalid_envelope' })); }
- const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
- return res.end(JSON.stringify(parsed.events ? { results } : results[0]));
- }
- res.statusCode = 404; res.end('{}');
- });
-});
-
-(async () => {
- await new Promise((r) => stub.listen(0, '127.0.0.1', r));
- const base = `http://127.0.0.1:${stub.address().port}`;
- process.env.OV_NETWORK_INTERNAL_URL = base;
- const { validate } = require('openvibe-contracts');
- const db = require('../server/db/database');
- db.initDb();
- const d = db.getDb();
- d.prepare("INSERT INTO users (id, username, display_name, password_hash, role) VALUES (1, 'boss', 'Boss', 'x', 'admin'), (2, 'spammer', 'Spammer', 'x', 'user')").run();
- d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '10', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ'), (2, 'network', '20', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR')").run();
- const rows = () => d.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get().n;
-
- // Outbox off: the row is written, nothing is queued.
- db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: { reason: 'spam' } });
- assert.strictEqual(rows(), 1);
-
- const streamEvents = require('../server/events/stream-events');
- const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
- const r = db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'global_ban', details: { reason: 'spam', ban_type: 'permanent' } });
- db.logModerationAction({ scope_type: 'stream', scope_id: '77', actor_user_id: 1, target_user_id: 2, action_type: 'stream_force_end', details: {} });
- db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'self_message_delete_all', details: {} });
- db.logModerationAction({ scope_type: 'channel', scope_id: 5, actor_user_id: 2, target_user_id: null, action_type: 'channel_settings_update', details: {} });
- db.logModerationAction({ scope_type: 'stream', scope_id: 5, actor_user_id: 2, target_user_id: 2, action_type: 'message_delete', details: {} });
- assert.strictEqual(rows(), 6, 'every action is still logged locally');
- await outbox.flush();
- const evs = published.filter((e) => e.event_type === 'live.moderation.action');
- assert.deepStrictEqual(evs.map((e) => e.payload.action_type), ['global_ban', 'stream_force_end'], 'only moderation of someone else');
- for (const e of evs) { const v = validate('live.moderation.action@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); }
- const [ban, end] = evs;
- assert.deepStrictEqual(ban.actor, { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });
- assert.deepStrictEqual(ban.subject, { type: 'moderation_action', id: String(r.lastInsertRowid) });
- assert.strictEqual(ban.payload.target_subject, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR');
- assert.deepStrictEqual(ban.payload.details, { reason: 'spam', ban_type: 'permanent' });
- assert.strictEqual(ban.priority, 'important'); assert.strictEqual(ban.visibility, 'internal');
- assert.strictEqual(end.payload.scope_id, 77);
-
- // The backfill keeps each action's original time.
- d.transaction(() => db.announceModerationAction(1, { scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: 'site_ban', details: {} }, { at: '2026-04-13T20:56:38.000Z' }))();
- await outbox.flush();
- assert.strictEqual(published.at(-1).timestamp, '2026-04-13T20:56:38.000Z');
-
- // A failed insert queues nothing (the event is in its transaction).
- const before = published.length;
- assert.throws(() => db.logModerationAction({ scope_type: 'site', actor_user_id: 1, target_user_id: 2, action_type: null }));
- await outbox.flush();
- assert.strictEqual(published.length, before);
-
- outbox.stop && outbox.stop();
- stub.close();
- fs.rmSync(tmp, { recursive: true, force: true });
- quiet('moderation events: all checks passed');
- process.exit(0);
-})().catch((err) => { console.error(err); process.exit(1); });
@@ -47,7 +47,6 @@ const SEED = `
@@ -47,7 +47,6 @@ const SEED = `
d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'N-1 live stream', 'tech', 'webrtc', 1, 2, datetime('now', '-10 minutes'), datetime('now'))").run(star);
d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'N-1 live stream', 'tech', 'webrtc', 1, 2, datetime('now', '-10 minutes'), datetime('now'))").run(star);
d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at) VALUES (?, 'N-1 past stream', 'irl', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))").run(star);
d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at) VALUES (?, 'N-1 past stream', 'irl', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))").run(star);
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(fan, star);
d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(fan, star);
- d.prepare("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, ?, 'n1fan', 'hello from N-1')").run(fan);
db.close();
db.close();
`;
`;
@@ -14,9 +14,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -14,9 +14,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'Late night tinkering', 'irl', 'webrtc', ts(70), ts(10), 3600, 9]);
db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'Late night tinkering', 'irl', 'webrtc', ts(70), ts(10), 3600, 9]);
const sid = db.get('SELECT id FROM streams ORDER BY id DESC LIMIT 1').id;
const sid = db.get('SELECT id FROM streams ORDER BY id DESC LIMIT 1').id;
for (let i = 0; i < 12; i++) db.run('INSERT INTO viewer_snapshots (stream_id, viewer_count, chat_messages_5m, recorded_at) VALUES (?,?,?,?)', [sid, 2 + Math.round(7 * Math.sin(i / 11 * Math.PI)), i === 6 ? 14 : 3, ts(70 - i * 5)]);
for (let i = 0; i < 12; i++) db.run('INSERT INTO viewer_snapshots (stream_id, viewer_count, chat_messages_5m, recorded_at) VALUES (?,?,?,?)', [sid, 2 + Math.round(7 * Math.sin(i / 11 * Math.PI)), i === 6 ? 14 : 3, ts(70 - i * 5)]);
-for (let i = 0; i < 9; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan, 'fan1', `msg ${i}`, ts(60 - i)]);
-for (let i = 0; i < 4; i++) db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, fan2, 'fan2', `yo ${i}`, ts(50 - i)]);
-db.run("INSERT INTO chat_messages (stream_id, user_id, username, message, timestamp) VALUES (?,?,?,?,?)", [sid, host, 'hostess', 'hi chat', ts(55)]);
+// Chat owns chat history now (Live keeps no copy): the 9 fan1 + 4 fan2 lines and the host's one
+// are what Chat's stats/top read answers, stubbed here. The host is excluded from the top list.
+const chatReads = require('../server/chat/chat-reads');
+chatReads.streamStats = async () => ({ messages: 14, chatters: 3, sounds: 0 });
+chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];
db.run("INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)", [fan2, host, ts(30)]);
db.run("INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)", [fan2, host, ts(30)]);
db.run("INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)", [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
db.run("INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)", [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
db.run("INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)", [fan, host, sid, 5, 'donation', 'completed']);
db.run("INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)", [fan, host, sid, 5, 'donation', 'completed']);
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
// Deploys as events (roadmap Wave 3 exit): the deploy notice queues live.release.deployed in the
// Deploys as events (roadmap Wave 3 exit): the deploy notice queues live.release.deployed in the
-// same transaction that records the commits as announced (and stores the chat row), publishes it
-// through Live's outbox, keeps the chat notice working (local row, or the Chat bridge), says
-// nothing on a restart without new code, and rolls the notice back if the event cannot be queued.
+// same transaction that records the commits as announced, publishes it through Live's outbox (Chat
+// folds the rolling card from that event alone — Live keeps no chat table), says nothing on a
+// restart without new code, and rolls the announcement back if the event cannot be queued.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -45,7 +45,6 @@ const stub = http.createServer((req, res) => {
@@ -45,7 +45,6 @@ const stub = http.createServer((req, res) => {
const streamEvents = require('../server/events/stream-events');
const streamEvents = require('../server/events/stream-events');
const quiet = { log() {}, warn() {} };
const quiet = { log() {}, warn() {} };
const outboxRows = () => raw.prepare('SELECT event_id, envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === releaseEvents.EVENT_TYPE);
const outboxRows = () => raw.prepare('SELECT event_id, envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === releaseEvents.EVENT_TYPE);
- const deployRows = () => raw.prepare("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%'").all();
// The envelope is a valid event-envelope@1.
// The envelope is a valid event-envelope@1.
const head = (await new Promise(r => require('child_process').execFile('git', ['rev-parse', 'HEAD'], { cwd: path.join(__dirname, '..') }, (e, o) => r(String(o).trim()))));
const head = (await new Promise(r => require('child_process').execFile('git', ['rev-parse', 'HEAD'], { cwd: path.join(__dirname, '..') }, (e, o) => r(String(o).trim()))));
@@ -57,12 +56,12 @@ const stub = http.createServer((req, res) => {
@@ -57,12 +56,12 @@ const stub = http.createServer((req, res) => {
assert.strictEqual(sample.payload.release, head.slice(0, 7));
assert.strictEqual(sample.payload.release, head.slice(0, 7));
assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);
assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);
- // Events off: the notice works exactly as before and nothing is queued.
+ // Events off: Chat has no other way to hear about a deploy (its ingress has no deploy endpoint),
+ // so nothing is announced or recorded — the next boot tries again.
let r = await dn.announce({ db, log: quiet });
let r = await dn.announce({ db, log: quiet });
- assert.ok(r.announced >= 1);
+ assert.strictEqual(r.announced, 0);
assert.strictEqual(r.event_id, null);
assert.strictEqual(r.event_id, null);
- assert.strictEqual(db.getSetting(dn.SETTING), head);
- assert.strictEqual(deployRows().length, 1);
+ assert.ok(!db.getSetting(dn.SETTING), 'not recorded as announced');
// Events on: a new deploy (the setting points elsewhere) queues and publishes the event.
// Events on: a new deploy (the setting points elsewhere) queues and publishes the event.
const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
@@ -90,20 +89,17 @@ const stub = http.createServer((req, res) => {
@@ -90,20 +89,17 @@ const stub = http.createServer((req, res) => {
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
assert.strictEqual(outboxRows().length, 1);
assert.strictEqual(outboxRows().length, 1);
- // The event cannot be queued: nothing is recorded (no chat row, setting unchanged), so the next
- // boot announces again — never a notice without its event.
+ // The event cannot be queued: nothing is recorded (setting unchanged), so the next boot
+ // announces again — never an announcement without its event.
db.setSetting(dn.SETTING, '');
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");
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, log: quiet });
r = await dn.announce({ db, log: quiet });
raw.exec('DROP TRIGGER temp.outbox_boom');
raw.exec('DROP TRIGGER temp.outbox_boom');
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
assert.strictEqual(db.getSetting(dn.SETTING), '');
assert.strictEqual(db.getSetting(dn.SETTING), '');
- assert.strictEqual(deployRows().map(x => x.metadata).join('|'), chatBefore, 'the chat row rolled back too');
assert.strictEqual(outboxRows().length, 1);
assert.strictEqual(outboxRows().length, 1);
- // CHAT_AUTHORITY=chat with Events on: Chat learns the deploy from the event alone (C-84).
- process.env.CHAT_AUTHORITY = 'chat';
+ // With Events on: Chat learns the deploy from the event alone (C-84).
r = await dn.announce({ db, log: quiet });
r = await dn.announce({ db, log: quiet });
assert.ok(r.announced >= 1);
assert.ok(r.announced >= 1);
assert.strictEqual(db.getSetting(dn.SETTING), head);
assert.strictEqual(db.getSetting(dn.SETTING), head);
@@ -118,7 +114,6 @@ const stub = http.createServer((req, res) => {
@@ -118,7 +114,6 @@ const stub = http.createServer((req, res) => {
db.setSetting(dn.SETTING, '');
db.setSetting(dn.SETTING, '');
r = await dn.announce({ db, log: quiet });
r = await dn.announce({ db, log: quiet });
delete process.env.EVENTS_PUBLISH;
delete process.env.EVENTS_PUBLISH;
- delete process.env.CHAT_AUTHORITY;
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
assert.strictEqual(db.getSetting(dn.SETTING), '', 'not recorded as announced');
assert.strictEqual(db.getSetting(dn.SETTING), '', 'not recorded as announced');
assert.strictEqual(outboxRows().length, 2, 'nothing queued while Events is off');
assert.strictEqual(outboxRows().length, 2, 'nothing queued while Events is off');
@@ -66,7 +66,7 @@ try {
@@ -66,7 +66,7 @@ try {
// Tables the newer release added, filled the way it fills them.
// Tables the newer release added, filled the way it fills them.
try { require('./server/events/search-documents').ensureSchema(); d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id); } catch (e) {}
try { require('./server/events/search-documents').ensureSchema(); d.prepare("INSERT INTO search_doc_pushes (user_id, hash, revision) VALUES (?, 'h', 1)").run(u.id); } catch (e) {}
try { require('./server/events/search-media-documents').ensureSchema(); d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run(); } catch (e) {}
try { require('./server/events/search-media-documents').ensureSchema(); d.prepare("INSERT INTO search_media_pushes (kind, media_id, hash, revision) VALUES ('vod', 1, 'h', 1)").run(); } catch (e) {}
- db.logModerationAction({ scope_type: 'site', actor_user_id: u.id, target_user_id: null, action_type: 'probe', details: {} });
+ db.recordEasterEggSolve('2026-01-01', 'rollback-probe', u.id);
const tables = d.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;
const tables = d.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;
process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables }) + '\\n');
process.stdout.write(JSON.stringify({ ok: true, user: u.id, tables }) + '\\n');
`);
`);
@@ -54,14 +54,20 @@ addUser(5, 'dests');
@@ -54,14 +54,20 @@ addUser(5, 'dests');
const e1 = slot(5, 'x'), e2 = slot(5, 'y');
const e1 = slot(5, 'x'), e2 = slot(5, 'y');
const r5 = rsRow(5, null, { robot_id: '11' });
const r5 = rsRow(5, null, { robot_id: '11' });
// Its streams on slot y carried mirrored RobotStreamer chat: the account-level row was serving y.
// Its streams on slot y carried mirrored RobotStreamer chat: the account-level row was serving y.
+// The script reads mirrored chat as evidence only when a legacy database still carries the table
+// (OpenVibe.Chat owns chat_messages and Live no longer creates it), so the fixture recreates it.
+raw.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, stream_id INTEGER, user_id INTEGER, username TEXT,
+ message TEXT, source_platform TEXT, is_deleted INTEGER DEFAULT 0, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`);
+const chat = (streamId, username, message, platform = null) => raw.prepare('INSERT INTO chat_messages (stream_id, username, message, source_platform) VALUES (?, ?, ?, ?)').run(streamId, username, message, platform);
const e2stream = Number(db.createStream({ user_id: 5, managed_stream_id: e2, title: 'y', protocol: 'webrtc' }).lastInsertRowid);
const e2stream = Number(db.createStream({ user_id: 5, managed_stream_id: e2, title: 'y', protocol: 'webrtc' }).lastInsertRowid);
db.endStream(e2stream);
db.endStream(e2stream);
-db.saveChatMessage({ stream_id: e2stream, username: '[RS] fan', message: 'hi', source_platform: 'rs' });
-db.saveChatMessage({ stream_id: e2stream, username: '[RS] fan', message: 'again', source_platform: 'rs' });
+chat(e2stream, '[RS] fan', 'hi', 'rs');
+chat(e2stream, '[RS] fan', 'again', 'rs');
// User 3's slot "one" was live with native chat only: that is no RobotStreamer evidence.
// User 3's slot "one" was live with native chat only: that is no RobotStreamer evidence.
const s1 = Number(db.createStream({ user_id: 3, managed_stream_id: c1, title: 'c1', protocol: 'webrtc' }).lastInsertRowid);
const s1 = Number(db.createStream({ user_id: 3, managed_stream_id: c1, title: 'c1', protocol: 'webrtc' }).lastInsertRowid);
db.endStream(s1);
db.endStream(s1);
-db.saveChatMessage({ stream_id: s1, username: 'viewer', message: 'native chat is no evidence' });
+chat(s1, 'viewer', 'native chat is no evidence');
const script = require('../scripts/rs-integrations-to-slots');
const script = require('../scripts/rs-integrations-to-slots');
const out = [];
const out = [];
@@ -88,7 +88,6 @@ const SEED = `
@@ -88,7 +88,6 @@ const SEED = `
ins('INSERT INTO api_tokens (user_id, token_hash, label, scopes) VALUES (?, ?, ?, ?)', [owner, K.apiTokenHash, 'Bot', '["chat","read"]']);
ins('INSERT INTO api_tokens (user_id, token_hash, label, scopes) VALUES (?, ?, ?, ?)', [owner, K.apiTokenHash, 'Bot', '["chat","read"]']);
const cam = Number(ins('INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, ?, ?, ?, ?)', [owner, live, 'Desk cam', 'http://camera.example.test', 'admin', K.cameraPasswordHash]).lastInsertRowid);
const cam = Number(ins('INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, ?, ?, ?, ?)', [owner, live, 'Desk cam', 'http://camera.example.test', 'admin', K.cameraPasswordHash]).lastInsertRowid);
db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, owner]);
db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, owner]);
- db.run("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (?, ?, 'keyfan', 'hello')", [live, fan]);
// What Live sends out about the stream, built from these rows the way production builds it.
// What Live sends out about the stream, built from these rows the way production builds it.
(async () => {
(async () => {
@@ -38,10 +38,13 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -38,10 +38,13 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
+ // Chat persists and shows the line (Live keeps no chat table): stub the ingress call and record
+ // what Live sends, and keep stubbing TTS synthesis (Chat speaks tts lines; the delivery seam is
+ // Live's only path).
const delivery = require('../server/chat/chat-delivery');
const delivery = require('../server/chat/chat-delivery');
- const broadcasts = [];
- delivery.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
- delivery.broadcastGlobal = () => {};
+ const sent = [];
+ let nextId = 700;
+ delivery.message = async (body) => { sent.push(body); return nextId++; };
const tts = [];
const tts = [];
delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
@@ -65,19 +68,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -65,19 +68,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
const a = await post(job);
const a = await post(job);
assert.strictEqual(a.status, 200);
assert.strictEqual(a.status, 200);
assert.ok(a.json.ref.chat_message_id > 0);
assert.ok(a.json.ref.chat_message_id > 0);
- assert.strictEqual(broadcasts.length, 1);
- assert.strictEqual(broadcasts[0].uid, 501);
- assert.strictEqual(broadcasts[0].ev.type, 'donation');
- assert.strictEqual(broadcasts[0].ev.amount, 250);
- const saved = d.prepare("SELECT * FROM chat_messages WHERE message_type = 'donation'").get();
- assert.strictEqual(saved.message, 'Viewer tipped 250 Vibes: gg');
- assert.strictEqual(JSON.parse(saved.metadata).source, 'tips');
+ assert.strictEqual(sent.length, 1);
+ assert.deepStrictEqual([sent[0].channel_user_id, sent[0].message_type, sent[0].mirror], [501, 'donation', true]);
+ assert.strictEqual(sent[0].message, 'Viewer tipped 250 Vibes: gg');
+ assert.strictEqual(sent[0].metadata.source, 'tips');
+ assert.strictEqual(sent[0].metadata.amount, 250);
// A retry of the same delivery changes nothing.
// A retry of the same delivery changes nothing.
const b = await post(job);
const b = await post(job);
assert.deepStrictEqual(b.json, a.json);
assert.deepStrictEqual(b.json, a.json);
- assert.strictEqual(broadcasts.length, 1);
- assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);
+ assert.strictEqual(sent.length, 1, 'the retry posts nothing to Chat');
// A Live restart between deliveries: the answered key is in SQLite, not in the old process.
// A Live restart between deliveries: the answered key is in SQLite, not in the old process.
delete require.cache[require.resolve('../server/tips/delivery-routes')];
delete require.cache[require.resolve('../server/tips/delivery-routes')];
@@ -91,17 +91,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -91,17 +91,16 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
const afterRestart = await post2(job, 'tint_1:chat_line');
const afterRestart = await post2(job, 'tint_1:chat_line');
assert.strictEqual(afterRestart.status, 200);
assert.strictEqual(afterRestart.status, 200);
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
- assert.strictEqual(broadcasts.length, 1, 'and delivers nothing again');
- assert.strictEqual(d.prepare("SELECT COUNT(*) AS n FROM chat_messages WHERE message_type = 'donation'").get().n, 1);
+ assert.strictEqual(sent.length, 1, 'and delivers nothing again');
// A retry while the first attempt is still running is told to come back; a claim left by a
// A retry while the first attempt is still running is told to come back; a claim left by a
// crash (older than two minutes) is taken over.
// crash (older than two minutes) is taken over.
d.prepare("INSERT INTO tips_deliveries (idempotency_key, effect, state, claimed_at) VALUES ('tint_9:chat_line', 'chat_line', 'pending', ?)").run(Date.now());
d.prepare("INSERT INTO tips_deliveries (idempotency_key, effect, state, claimed_at) VALUES ('tint_9:chat_line', 'chat_line', 'pending', ?)").run(Date.now());
assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 409);
assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 409);
- assert.strictEqual(broadcasts.length, 1);
+ assert.strictEqual(sent.length, 1);
d.prepare("UPDATE tips_deliveries SET claimed_at = ? WHERE idempotency_key = 'tint_9:chat_line'").run(Date.now() - 5 * 60 * 1000);
d.prepare("UPDATE tips_deliveries SET claimed_at = ? WHERE idempotency_key = 'tint_9:chat_line'").run(Date.now() - 5 * 60 * 1000);
assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 200);
assert.strictEqual((await post2({ ...job, interaction: { ...job.interaction, id: 'tint_9' } }, 'tint_9:chat_line')).status, 200);
- assert.strictEqual(broadcasts.length, 2);
+ assert.strictEqual(sent.length, 2);
// A refused delivery gives its key back, so Tips' retry runs it.
// A refused delivery gives its key back, so Tips' retry runs it.
const gone = { ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000X' } };
const gone = { ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000X' } };
@@ -125,10 +124,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -125,10 +124,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
// A hidden amount (Tips privacy.hide_amount sends null) stays hidden: no "0 Vibes" anywhere.
// A hidden amount (Tips privacy.hide_amount sends null) stays hidden: no "0 Vibes" anywhere.
const h = await post({ ...job, text: undefined, interaction: { ...job.interaction, id: 'tint_h', amount: null, message: 'hi' } }, { key: 'tint_h:chat_line' });
const h = await post({ ...job, text: undefined, interaction: { ...job.interaction, id: 'tint_h', amount: null, message: 'hi' } }, { key: 'tint_h:chat_line' });
assert.strictEqual(h.status, 200);
assert.strictEqual(h.status, 200);
- assert.strictEqual(broadcasts[broadcasts.length - 1].ev.amount, null);
- const hs = d.prepare('SELECT * FROM chat_messages WHERE id = ?').get(h.json.ref.chat_message_id);
- assert.strictEqual(hs.message, 'Viewer sent a tip: hi');
- assert.strictEqual(JSON.parse(hs.metadata).amount, null);
+ assert.strictEqual(sent[sent.length - 1].message, 'Viewer sent a tip: hi');
+ assert.strictEqual(sent[sent.length - 1].metadata.amount, null);
// Unknown creator: permanent refusal.
// Unknown creator: permanent refusal.
const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });