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)

OP OpenVibers committed 5 Oct 2026 · 1d49484e3a · 50 files, +758 −2710

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

Everything is green. Final summary:

---

**One deviation, flagged up front:** the drop is an **operator migration** (`op_003_drop_chat_tables`), not a boot migration. I verified empirically that a boot drop breaks `test/n-1.test.js` (explicitly keep-green): the fixtures (recorded 2026-10-05 from production release `2d0fa0e`) still contain 52 statements that prepare against these tables, so dropping them at boot fails ~50 of the 931 N-1 SQL checks. The repo's ADR-028 / `op_002_drop_chat_bridge_outbox` precedent is exactly this case ("N-1 still writes the table, so the drop waits until N-1 is out of rollback range"). The migration drops all twelve + their indexes in FK order and is applied with `node scripts/chat-tables-drop.js --apply` once the previous release leaves rollback range (backup taken first). If you want it at boot instead, it is a matter of moving the `up` into `MIGRATIONS` **and** re-recording the n-1 fixtures.

**Changes**
- `server/admin/routes.js:234` — deleted the `UPDATE chat_messages SET username` user-rename block (Chat keeps names via `ctx_users`); the `delivery.invalidate` hint below stays.
- `server/ai/ai-moments-job.js:140` — `db.getChatSpikeOffsets` fallback removed; no start time now yields `[]`.
- `server/db/schema.sql` — `chat_messages` + `channel_sounds` CREATE/INDEX blocks removed (fresh DBs create none of the twelve).
- `server/db/database.js` — every inline creator/backfill/ALTER for the twelve removed (old ~416-2043 and the index loops); every helper over them deleted (~1,900 lines net); `HOME_SERIES` drops `messages`/`active` (keys kept in `HOME_SERIES_KEYS:3454`, routed to `chat-reads`); exports trimmed.
- `server/db/migrations.js:191` — `op_003_drop_chat_tables` (operator): `DROP INDEX`/`DROP TABLE IF EXISTS` for the twelve, dm_participants/dm_messages before dm_conversations; no triggers ever existed on them (verified).
- `server/chat/dm.js` — deleted (nothing in chat mode used it after #32; Live had no `/api/dm` mount — nginx routes it to Chat); boot calls removed at `server/index.js:1191,1654`.
- `server/chat/chat-reads.js:146-...` — all local (non-chat-mode) fallbacks that read the twelve now answer null/empty (`localSiteStats`, `localSiteStatsMessages`, `localWindowStats`, `localStreamStats`, topChatters, search/history/samples, channelMaxId, firstChat, queues, tts, sounds, dmBlocked, pendingSounds, recordSoundAsset); comments rewritten.
- `server/admin/mod-routes.js:408,450,497,509,536,615,648,680,879,896` — non-ingress branches over the tables removed; deletes/hides/IP queue/TTS overrides now always go through Chat; the pending-IP count reads Chat.
- `server/chat/chat-delivery.js:79-87` — `logModeration` always routes to Chat (Live keeps no moderation log).
- `server/chat/deploy-notice.js:44-79` — `persist`/`plainText` deleted; the notice is only the `live.release.deployed` event (Chat folds the card); failure reports `announced: 0`.
- `server/integrations/chat-relay-service.js:405-409` — relay lines/welcome always via `_deliverToChat` (Chat persists, records first chat, TTS).
- `server/integrations/robotstreamer-service.js:660-700` — same for RS: relay-record, `!sound` trigger and lines go to Chat only.
- Producers collapsed to the Chat ingress: `server/ai/viewers/poster.js:47-58`, `server/tips/delivery-routes.js:135-137`, `server/media-proxy/clip-notify.js:72-78`, `server/monetization/routes.js:96-111,138-140`, `server/integrations/powerchat-webhook.js:94-99`.
- `server/media-proxy/asset-syn

* Account deletion keeps erasing the person from Live's legacy chat copies while they exist; export skips them (review)

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 192c6d3d..f1189ebe 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 95409d03..67941bb5 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 69821b27..3ba261eb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 423d9384..352c383a 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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 once
through the SDK inbox (`idempotency_receipts`):
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 5eaeec29..172131a0 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -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 the
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
@@@ -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
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
--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.
ddiff --git a/docs/cutover-chat-tables-003.md b/docs/cutover-chat-tables-003.md
nnew file mode 100644
iindex 00000000..80886c45
---- /dev/null
++++ b/docs/cutover-chat-tables-003.md
@@@ -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).
ddiff --git a/scripts/backfill-moderation-events.js b/scripts/backfill-moderation-events.js
ddeleted file mode 100644
iindex c126c1f0..00000000
---- a/scripts/backfill-moderation-events.js
++++ /dev/null
@@@ -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); });
ddiff --git a/scripts/chat-tables-drop.js b/scripts/chat-tables-drop.js
nnew file mode 100644
iindex 00000000..b56adda6
---- /dev/null
++++ b/scripts/chat-tables-drop.js
@@@ -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 };
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex ae69d8aa..c36c3201 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -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);
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 4cbb2fb5..07f327f6 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex ae054c4f..4c7546f5 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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 = [];
ddiff --git a/server/ai/context/index.js b/server/ai/context/index.js
iindex c5f08412..301f2c13 100644
---- a/server/ai/context/index.js
++++ b/server/ai/context/index.js
@@@ -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 { /* */ }
ddiff --git a/server/ai/viewers/poster.js b/server/ai/viewers/poster.js
iindex f8ac7919..97ea6509 100644
---- a/server/ai/viewers/poster.js
++++ b/server/ai/viewers/poster.js
@@@ -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) {
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 585148df..34a021ac 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -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 export
const 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 ');
ddiff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
iindex 634ee3d5..1f5f8cd4 100644
---- a/server/chat/chat-authority.js
++++ b/server/chat/chat-authority.js
@@@ -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.
ddiff --git a/server/chat/chat-delivery.js b/server/chat/chat-delivery.js
iindex 0a713257..3e18bd2a 100644
---- a/server/chat/chat-delivery.js
++++ b/server/chat/chat-delivery.js
@@@ -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);
ddiff --git a/server/chat/chat-reads.js b/server/chat/chat-reads.js
iindex 82606e2f..363865f6 100644
---- a/server/chat/chat-reads.js
++++ b/server/chat/chat-reads.js
@@@ -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 = n
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);
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 = 30
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');
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: stable
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) {
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_users
const 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 answer
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) {
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 pl
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
-- * 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 closed
strict: 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 on
return client.soundAsset({ id: Number(id), media_url: String(mediaUrl), media_asset_id: Number(mediaAssetId) });
}
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
iindex 1c2c2bc9..d33b7447 100644
---- a/server/chat/deploy-notice.js
++++ b/server/chat/deploy-notice.js
@@@ -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 };
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
ddeleted file mode 100644
iindex 987c0df3..00000000
---- a/server/chat/dm.js
++++ /dev/null
@@@ -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,
--};
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex f2b00aa1..4e127e75 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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');
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a2fde8b4..db8230ea 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 channels
try {
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 missing
try {
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 anons
try {
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_seen
FROM 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_cached
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 } = {}) {
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,
// Profiles
getUserProfile, updateUserAvatar,
getKickChannelCache, setKickChannelCache,
@@@ -7115,8 +6018,6 @@ module.exports = {
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
isUserBanned, 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 Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
-- // Chat Replay
-- getChatReplay,
// Channel lookup
getChannelById,
getUserTotalGameLevel, getLegacyGameProfile,
// Anon IP Mappings
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)
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
logIp, getIpsByUser, getUsersByIp, getLinkedAccounts, getLinkedAccountsByAnon,
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
// Stream Analytics
insertViewerSnapshot, getViewerSnapshots, computeAndCacheStreamAnalytics,
-- getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary, getRecentChatActivity,
++ getStreamAnalytics, setStreamAnalyticsClipCount, setStreamAnalyticsChatTotals, getChannelAnalyticsSummary,
// User Preferences
getUserPreferences, saveUserPreferences,
-- // Chat Log Management
-- deleteChatMessagesByTimeRange, countChatMessagesByTimeRange, getChatLogs,
// API Tokens
createApiToken, listApiTokens, revokeApiToken, validateApiToken,
};
ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex cb00c738..441f93c5 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -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
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 3a38df4d..e0806ffd 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -145,29 +145,6 @@ CREATE TABLE IF NOT EXISTS cameras (
FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE
);
---- Chat messages (stored for moderation / VOD replay)
--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, conten
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
---- Per-channel viewer-uploadable sound commands (triggered by !name in chat)
--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.
ddiff --git a/server/index.js b/server/index.js
iindex 2b6c4d6b..02c6706c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 missing
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
@@@ -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); });
ddiff --git a/server/integrations/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex 1c50330a..88d913a5 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -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. */
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 07e63547..5501514d 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -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', messag
let 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 : ''}`,
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex f9f73dcd..00b3dd01 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -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', () => {
ddiff --git a/server/media-proxy/asset-sync.js b/server/media-proxy/asset-sync.js
iindex 190ab107..f482d0ff 100644
---- a/server/media-proxy/asset-sync.js
++++ b/server/media-proxy/asset-sync.js
@@@ -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 (;;) {
ddiff --git a/server/media-proxy/clip-notify.js b/server/media-proxy/clip-notify.js
iindex ae86abe4..14b06eb9 100644
---- a/server/media-proxy/clip-notify.js
++++ b/server/media-proxy/clip-notify.js
@@@ -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);
}
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 85eeb0e1..0f186e63 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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');
}
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex d2e09984..adc8ecb4 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -131,16 +131,8 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
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 },
};
-- 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 } }));
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 70e58a9d..a66d674e 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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.js
const assert = require('assert');
@@@ -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 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);
ddiff --git a/test/authorization.test.js b/test/authorization.test.js
iindex 044537f7..bae0c16c 100644
---- a/test/authorization.test.js
++++ b/test/authorization.test.js
@@@ -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);
});
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex ffe4f127..f1a38d18 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -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 () => {
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 0d50a271..8b1c1e82 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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 nothing
chatReads._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 await
const 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 answer
const 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();
ddiff --git a/test/chat-history-store.test.js b/test/chat-history-store.test.js
iindex 45ff824b..add8b317 100644
---- a/test/chat-history-store.test.js
++++ b/test/chat-history-store.test.js
@@@ -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 answers
const 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); });
ddiff --git a/test/chat-ingress.test.js b/test/chat-ingress.test.js
iindex 57642651..9d2fd1e4 100644
---- a/test/chat-ingress.test.js
++++ b/test/chat-ingress.test.js
@@@ -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 directly
await 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 does
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');
@@@ -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}`);
ddiff --git a/test/chat-tables-dropped.test.js b/test/chat-tables-dropped.test.js
nnew file mode 100644
iindex 00000000..7dbe3b16
---- /dev/null
++++ b/test/chat-tables-dropped.test.js
@@@ -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);
ddiff --git a/test/deploy-notice.test.js b/test/deploy-notice.test.js
ddeleted file mode 100644
iindex 3a108434..00000000
---- a/test/deploy-notice.test.js
++++ /dev/null
@@@ -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');
ddiff --git a/test/dm-delivery.test.js b/test/dm-delivery.test.js
ddeleted file mode 100644
iindex 429df5bd..00000000
---- a/test/dm-delivery.test.js
++++ /dev/null
@@@ -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);
--});
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex f9bc208e..12686e15 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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');
ddiff --git a/test/fixtures/destructive-migrations.json b/test/fixtures/destructive-migrations.json
iindex 5eb73d65..0127d8aa 100644
---- a/test/fixtures/destructive-migrations.json
++++ b/test/fixtures/destructive-migrations.json
@@@ -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" }
]
}
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
ddeleted file mode 100644
iindex e1d14b23..00000000
---- a/test/moderation-events.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 40a309a3..9f106f3c 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -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();
`;
ddiff --git a/test/recap.test.js b/test/recap.test.js
iindex 75ca6a04..e3390eee 100644
---- a/test/recap.test.js
++++ b/test/recap.test.js
@@@ -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]);
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']);
ddiff --git a/test/release-events.test.js b/test/release-events.test.js
iindex d7316019..f5c01c24 100644
---- a/test/release-events.test.js
++++ b/test/release-events.test.js
@@@ -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');
ddiff --git a/test/rollback-newer-writes.test.js b/test/rollback-newer-writes.test.js
iindex 85155ca6..ffe8c223 100644
---- a/test/rollback-newer-writes.test.js
++++ b/test/rollback-newer-writes.test.js
@@@ -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');
`);
ddiff --git a/test/rs-integrations-to-slots.test.js b/test/rs-integrations-to-slots.test.js
iindex e4ca5bf0..57bea970 100644
---- a/test/rs-integrations-to-slots.test.js
++++ b/test/rs-integrations-to-slots.test.js
@@@ -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 = [];
ddiff --git a/test/security-stream-keys.test.js b/test/security-stream-keys.test.js
iindex 52952c98..7a925825 100644
---- a/test/security-stream-keys.test.js
++++ b/test/security-stream-keys.test.js
@@@ -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 () => {
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 5af2612c..5ff06339 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -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 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({ iss
const 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({ iss
const 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' });