openvibers/openvibe.live Commits

Live's T3 N+1 release: the staged-table machinery, Live's chat-AI job and the old emote and channel-moderation routes are deleted (plan T3 step 5; OpenVibe.Chat has written the six tables since 14:46 UTC and serves their APIs).

OP OpenVibers committed 29 Sept 2026 · 8654ef6b86 · 35 files, +399 −3141

Deleted: server/chat/chat-tables.js and chat-tables-sync.js (the write relay, dual read and authority switch) with their init and the /internal/chat-tables routes; server/ai/chat-ai.js (Chat runs the summaries now); server/emotes/routes.js (nginx sends /api/emotes to Chat); server/admin/channel-mod-routes.js (the dashboard and channel page call Chat's /api/chat/channels/…); Live's global, timeline, anon and relay chat-AI routes (Chat's /api/chat/ai/…); the 35 database functions that only served the six tables; their tests. The tables themselves, chat_staged_outbox and chat_dual_read_stats stay one release for N-1 (ledger in schema.sql); test/chat-staged-tables.test.js guards that nothing reads or writes them.

Kept on Live, fused with Chat's chatter insight through the new server/chat/insight-client.js (Chat's public /api/chat/ai/* answers, cached 60 s, sync peeks for the persona context, null when Chat is down): /api/chat-ai/timeline/:username, the channel page's AI timeline (streamer overview, sessions, VOD moments, combined overview), and /api/chat-ai/user/:id, the user card with its streamer side. Live N had pointed both at Chat's paths, which cannot build the streamer half, so the channel AI timeline was empty in production since 16:26; the frontend calls Live's routes again. The persona's chat mood and per-chatter memory, the hero slogans' chat block, the easter egg's vibe and the viewer clone's insight read through the same client. The last frontend call on the old moderation path moves to Chat's; the API documentation names the new owners.

npm test 154/155 (1 skipped: no local model); test/chat-insight-client.test.js new.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex f05e0c1b..b81e53eb 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 keeps no copy; 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 `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; the six tables stay one more release for N-1 (their write relay/dual read and Live's chat-AI job were deleted; `test/chat-staged-tables.test.js` guards that nothing else reads or writes 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/docs/chat-system.md b/docs/chat-system.md
iindex adf851eb..742bc939 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -37,6 +37,9 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
`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 through the bridge op
`playAlertSound [streamerId, streamId, kind]` ([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; the
++ six tables (and `chat_staged_outbox`/`chat_dual_read_stats`) stay one release for N-1 (ADR-016).
## Features
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 7b3c737a..2b362054 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -575,15 +575,17 @@ while True:
<h2>AI SYSTEMS</h2>
<p>OpenVibe.Live runs a background AI layer over community content (chat, streams, VODs) using a shared platform key with a per-streamer daily budget, or a streamer's own BYO key. Two developer-facing surfaces:</p>
--<h3>Chat-AI insight (read-only) &mdash; <code>/api/chat-ai</code></h3>
--<p>Rolling LLM summaries produced by the chat-AI poller: a global "community vibe", plus per-user / per-anon / per-relay-user insight (overview + running memory + a short timeline). No auth required to read.</p>
++<h3>Chat-AI insight (read-only) &mdash; <code>/api/chat/ai</code> and <code>/api/chat-ai</code></h3>
++<p>Rolling LLM summaries of the chat, kept by OpenVibe.Chat: a global "community vibe", plus per-user / per-anon / per-relay-user insight (overview + running memory + a short timeline). A channel's AI timeline and a user's streamer side are OpenVibe.Live's and include the chatter insight. No auth required to read.</p>
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>GET</td><td>/api/chat-ai/global</td><td>Site-wide chat overview + timeline + memory</td></tr>
--<tr><td>GET</td><td>/api/chat-ai/user/:id</td><td>A user's "today vs all-time" insight (+ streamer overview if they broadcast)</td></tr>
--<tr><td>GET</td><td>/api/chat-ai/anon/:anonId</td><td>Anon chatter insight (id like <code>anon12345</code>)</td></tr>
--<tr><td>GET</td><td>/api/chat-ai/relay/:platform/:username</td><td>Bridged external (Twitch/Kick/YouTube/RS) chatter insight</td></tr>
++<tr><td>GET</td><td>/api/chat/ai/global</td><td>Site-wide chat overview + timeline + memory</td></tr>
++<tr><td>GET</td><td>/api/chat/ai/timeline</td><td>The global chat timeline: <code>?before=</code>, <code>?since=</code> (ms), <code>?q=</code>, <code>?limit=</code></td></tr>
++<tr><td>GET</td><td>/api/chat-ai/user/:id</td><td>A user's "today vs all-time" chat insight + their streamer overview and stream memories if they broadcast</td></tr>
++<tr><td>GET</td><td>/api/chat/ai/anon/:anonId</td><td>Anon chatter insight (id like <code>anon12345</code>)</td></tr>
++<tr><td>GET</td><td>/api/chat/ai/relay/:platform/:username</td><td>Bridged external (Twitch/Kick/YouTube/RS) chatter insight</td></tr>
++<tr><td>GET</td><td>/api/chat-ai/timeline/:username</td><td>A channel's AI timeline: streamer overview, every session's overview and VOD moments (<code>?offset=</code>, <code>?limit=</code>)</td></tr>
</tbody>
</table>
@@@ -1246,7 +1248,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</tbody>
</table>
--<h3>Emotes — <code>/api/emotes</code></h3>
++<h3>Emotes — <code>/api/emotes</code> (served by OpenVibe.Chat)</h3>
<table class="doc-table">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 83dd9aba..e146677e 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2097,7 +2097,7 @@ async function _aiTlLoadMore() {
_aiTl.loading = true;
const track = document.getElementById('ai-tl-track');
try {
-- const data = await api(`/chat/ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);
++ const data = await api(`/chat-ai/timeline/${encodeURIComponent(_aiTl.username)}?offset=${_aiTl.offset}&limit=${_aiTl.limit}`);
const sessions = data.sessions || [];
if (track && sessions.length) track.insertAdjacentHTML('beforeend', sessions.map(_aiTimelineSessionHTML).join(''));
_aiTl.offset += sessions.length;
@@@ -2114,7 +2114,7 @@ async function loadChannelAiTimeline(username) {
wrap.innerHTML = '<div class="loading">Loading AI timeline…</div>';
_aiTl = { username, offset: 0, limit: 12, hasMore: false, loading: false, moments: {}, vodBySid: {}, index: null, io: null };
try {
-- const data = await api(`/chat/ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);
++ const data = await api(`/chat-ai/timeline/${encodeURIComponent(username)}?offset=0&limit=12`);
const sessions = data.sessions || [];
const dn = esc(data.display_name || username);
const streamerOv = data.overview && (data.overview.overview || data.overview.overview_short);
@@@ -2781,7 +2781,7 @@ function _setAboutPanelWidth(i, w, sel) {
async function _loadAboutModsSetting() {
if (!currentUser) return;
try {
-- const data = await api('/channels/moderation/mine');
++ const data = await api('/chat/channels/moderation/mine');
const mine = (data.channels || []).find(c => c.user_id === currentUser.id) || (data.channels || [])[0];
if (mine) {
_aboutChannelId = mine.id;
ddiff --git a/public/js/app-chatpage.js b/public/js/app-chatpage.js
iindex 86dc782f..03756d4d 100644
---- a/public/js/app-chatpage.js
++++ b/public/js/app-chatpage.js
@@@ -42,7 +42,7 @@ async function loadGlobalChatAi() {
if (insight.updated_at) bits.push('updated ' + _aiTimeAgo(insight.updated_at));
meta.textContent = bits.join(' · ');
}
-- // The timeline is now a browsable/searchable, infinite-scroll list (fed by /chat-ai/timeline).
++ // The timeline is now a browsable/searchable, infinite-scroll list (fed by /chat/ai/timeline).
// Initialise once so polling doesn't clobber the user's scroll/search.
if (document.getElementById('global-ai-timeline') && !_gaiTlInited) { _gaiTlInited = true; _gaiTlInit(); }
const mem = document.getElementById('global-ai-memory');
ddiff --git a/public/js/app.js b/public/js/app.js
iindex e552d14d..13026226 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1515,7 +1515,7 @@ window._uaiRevealMore = _uaiRevealMore;
async function openUserChatInsight(userId, username) {
if (!userId) return;
-- return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat/ai/user/${userId}` });
++ return _openChatInsightModal({ title: username || 'User', username: username || '', iconClass: 'fa-user-tag', fetchUrl: `/chat-ai/user/${userId}` });
}
window.openUserChatInsight = openUserChatInsight;
ddiff --git a/server/admin/channel-mod-routes.js b/server/admin/channel-mod-routes.js
ddeleted file mode 100644
iindex 2c1d001b..00000000
---- a/server/admin/channel-mod-routes.js
++++ /dev/null
@@@ -1,371 +0,0 @@
--/**
-- * OpenVibe.Live — Channel Moderator Routes
-- *
-- * Manage per-channel mod assignments.
-- *
-- * GET /api/channels/moderation/mine - Channels the user owns/moderates
-- * GET /api/channels/:channelId/mods - List channel mods
-- * POST /api/channels/:channelId/mods - Add channel mod (channel owner or admin)
-- * DELETE /api/channels/:channelId/mods/:userId - Remove channel mod
-- * GET /api/channels/:channelId/moderation - Get channel moderation settings
-- * PUT /api/channels/:channelId/moderation - Update channel moderation settings
-- * GET /api/channels/:channelId/moderation/logs - View moderation action log
-- * GET /api/channels/:channelId/moderation/chat-search - Search channel chat
-- * POST /api/channels/:channelId/moderation/messages/:messageId/delete - Delete a chat message
-- */
--const express = require('express');
--const db = require('../db/database');
--const { requireAuth } = require('../auth/auth');
--const permissions = require('../auth/permissions');
--const chatServer = require('../chat/chat-server');
--// Moderators and settings are written wherever their table's authority is (Live or OpenVibe.Chat).
--const chatTables = require('../chat/chat-tables');
--
--const router = express.Router({ mergeParams: true });
--
--/** Parse a boolean from various input formats. */
--function parseBoolean(value, fallback) {
-- if (value === undefined || value === null) return fallback;
-- if (typeof value === 'boolean') return value;
-- if (typeof value === 'number') return value !== 0;
-- if (typeof value === 'string') {
-- return !['0', 'false', 'off', 'no'].includes(value.toLowerCase());
-- }
-- return fallback;
--}
--
--/** Middleware: require channel access (owner, channel mod, or staff). */
--async function requireChannelAccess(req, res, next) {
-- const channelId = parseInt(req.params.channelId, 10);
-- if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
-- const channel = db.getChannelById(channelId);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-- if (!(await permissions.canModerateChannel(req.user, channelId))) {
-- return res.status(403).json({ error: 'Channel moderation access required' });
-- }
-- req.channel = channel;
-- next();
--}
--
--// ── My moderation access ─────────────────────────────────────
--router.get('/moderation/mine', requireAuth, (req, res) => {
-- try {
-- const owned = db.getChannelByUserId(req.user.id);
-- const moderated = db.getChannelsByModerator(req.user.id) || [];
--
-- // Combine owned + moderated, dedup
-- const channelMap = new Map();
-- if (owned) channelMap.set(owned.id, owned);
-- for (const ch of moderated) {
-- if (!channelMap.has(ch.id)) channelMap.set(ch.id, ch);
-- }
--
-- const channels = [...channelMap.values()].map(channel => ({
-- ...channel,
-- moderation_settings: db.getChannelModerationSettings(channel.id),
-- moderators: db.getChannelModerators(channel.id),
-- }));
--
-- res.json({ channels });
-- } catch (err) {
-- console.error('[ChannelMod] Failed to load moderation access:', err.message);
-- res.status(500).json({ error: 'Failed to load channel moderation access' });
-- }
--});
--
--// ── List channel mods ────────────────────────────────────────
--router.get('/:channelId/mods', requireAuth, (req, res) => {
-- try {
-- const channelId = parseInt(req.params.channelId);
-- const channel = db.getChannelById(channelId);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
--
-- const mods = db.getChannelModerators(channelId);
-- res.json({ moderators: mods, channel_id: channelId });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to list channel moderators' });
-- }
--});
--
--// ── Add channel mod ──────────────────────────────────────────
--router.post('/:channelId/mods', requireAuth, async (req, res) => {
-- try {
-- const channelId = parseInt(req.params.channelId);
-- const channel = db.getChannelById(channelId);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
--
-- if (!permissions.canAssignChannelMods(req.user, channelId)) {
-- return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });
-- }
--
-- const { username } = req.body;
-- if (!username) return res.status(400).json({ error: 'Username is required' });
--
-- const targetUser = db.getUserByUsername(username);
-- if (!targetUser) return res.status(404).json({ error: 'User not found' });
--
-- if (targetUser.id === channel.user_id) {
-- return res.status(400).json({ error: 'Channel owner is already a moderator' });
-- }
--
-- await chatTables.write('addChannelModerator', channelId, targetUser.id, req.user.id);
--
-- db.logModerationAction({
-- scope_type: 'channel',
-- scope_id: channelId,
-- actor_user_id: req.user.id,
-- target_user_id: targetUser.id,
-- action_type: 'channel_mod_add',
-- details: { channel_id: channelId, username: targetUser.username },
-- });
--
-- res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to add channel moderator' });
-- }
--});
--
--// ── Remove channel mod ───────────────────────────────────────
--router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
-- try {
-- const channelId = parseInt(req.params.channelId);
-- const userId = parseInt(req.params.userId);
-- const channel = db.getChannelById(channelId);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
--
-- if (!permissions.canAssignChannelMods(req.user, channelId)) {
-- return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });
-- }
--
-- const target = db.getUserById(userId);
-- await chatTables.write('removeChannelModerator', channelId, userId);
--
-- db.logModerationAction({
-- scope_type: 'channel',
-- scope_id: channelId,
-- actor_user_id: req.user.id,
-- target_user_id: userId,
-- action_type: 'channel_mod_remove',
-- details: { channel_id: channelId, username: target?.username || String(userId) },
-- });
--
-- res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to remove channel moderator' });
-- }
--});
--
--// ── Get channel moderation settings ──────────────────────────
--router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {
-- try {
-- const settings = db.getChannelModerationSettings(req.channel.id);
-- res.json({ settings });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get moderation settings' });
-- }
--});
--
--// ── Update channel moderation settings ───────────────────────
--// Settings that decide what moderators themselves may do, or who can chat at all, belong to the
--// channel owner. A mod could otherwise grant themselves About-page editing or switch off IP approval.
--const OWNER_POLICY_KEYS = ['mods_can_edit_about', 'ip_approval_mode', 'uploads_mods_only', 'allow_anonymous', 'sounds_mods_only'];
--router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {
-- try {
-- const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);
-- // The dashboard form sends every field, so for a moderator these are ignored rather than refused.
-- if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
-- const settings = await chatTables.write('upsertChannelModerationSettings', req.channel.id, {
-- // The column is slow_mode_seconds. The dashboard used to send (and read) slowmode_seconds,
-- // which no writer knows, so its slow mode was never saved; a cached dashboard still sends it.
-- slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
-- ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
-- followers_only: req.body.followers_only !== undefined
-- ? parseBoolean(req.body.followers_only, false) : undefined,
-- // Sub-only chat (enforced by OpenVibe.Chat): active subscribers and the channel's moderators.
-- sub_only: req.body.sub_only !== undefined
-- ? parseBoolean(req.body.sub_only, false) : undefined,
-- emote_only: req.body.emote_only,
-- allow_anonymous: req.body.allow_anonymous !== undefined
-- ? parseBoolean(req.body.allow_anonymous, true) : undefined,
-- links_allowed: req.body.links_allowed !== undefined
-- ? parseBoolean(req.body.links_allowed, true) : undefined,
-- gifs_enabled: req.body.gifs_enabled !== undefined
-- ? parseBoolean(req.body.gifs_enabled, true) : undefined,
-- aggressive_filter: req.body.aggressive_filter !== undefined
-- ? parseBoolean(req.body.aggressive_filter, false) : undefined,
-- account_age_gate_hours: req.body.account_age_gate_hours !== undefined
-- ? Math.max(0, parseInt(req.body.account_age_gate_hours) || 0) : undefined,
-- caps_percentage_limit: req.body.caps_percentage_limit !== undefined
-- ? Math.min(100, Math.max(0, parseInt(req.body.caps_percentage_limit) || 70)) : undefined,
-- // Streamers can raise their chat cap up to 4000 chars; admins up to 6000. TTS is
-- // capped at 1200 for everyone (a very long TTS line is a griefing/queue-clog vector).
-- max_message_length: req.body.max_message_length !== undefined
-- ? Math.min(permissions.isAdmin(req.user) ? 6000 : 4000, Math.max(1, parseInt(req.body.max_message_length) || 500)) : undefined,
-- tts_max_length: req.body.tts_max_length !== undefined
-- ? Math.min(1200, Math.max(10, parseInt(req.body.tts_max_length) || 200)) : undefined,
-- slur_filter_enabled: req.body.slur_filter_enabled !== undefined
-- ? parseBoolean(req.body.slur_filter_enabled, false) : undefined,
-- slur_filter_use_builtin: req.body.slur_filter_use_builtin !== undefined
-- ? parseBoolean(req.body.slur_filter_use_builtin, true) : undefined,
-- slur_filter_terms: req.body.slur_filter_terms !== undefined
-- ? String(req.body.slur_filter_terms || '').slice(0, 4000) : undefined,
-- slur_filter_regexes: req.body.slur_filter_regexes !== undefined
-- ? String(req.body.slur_filter_regexes || '').slice(0, 8000) : undefined,
-- slur_filter_nudge_message: req.body.slur_filter_nudge_message !== undefined
-- ? String(req.body.slur_filter_nudge_message || '').slice(0, 800) : undefined,
-- slur_filter_disabled_categories: req.body.slur_filter_disabled_categories !== undefined
-- ? (() => {
-- const VALID_CATS = new Set(['n_word', 'antisemitic', 'homophobic', 'racial']);
-- let val = req.body.slur_filter_disabled_categories;
-- if (typeof val === 'string') {
-- try { val = JSON.parse(val); } catch { val = []; }
-- }
-- if (!Array.isArray(val)) return '[]';
-- return JSON.stringify(val.filter((k) => typeof k === 'string' && VALID_CATS.has(k)));
-- })() : undefined,
-- ip_approval_mode: req.body.ip_approval_mode !== undefined
-- ? parseBoolean(req.body.ip_approval_mode, false) : undefined,
-- soundboard_enabled: req.body.soundboard_enabled !== undefined
-- ? parseBoolean(req.body.soundboard_enabled, true) : undefined,
-- soundboard_allow_pitch: req.body.soundboard_allow_pitch !== undefined
-- ? parseBoolean(req.body.soundboard_allow_pitch, true) : undefined,
-- soundboard_allow_speed: req.body.soundboard_allow_speed !== undefined
-- ? parseBoolean(req.body.soundboard_allow_speed, true) : undefined,
-- soundboard_banned_ids: req.body.soundboard_banned_ids !== undefined
-- ? (() => {
-- const raw = String(req.body.soundboard_banned_ids || '');
-- const ids = raw.split(/[\s,\n\r]+/)
-- .map((s) => {
-- const m = s.trim().match(/101soundboards\.com\/sounds\/(\d+)/i);
-- if (m) return m[1];
-- if (/^\d{2,}$/.test(s.trim())) return s.trim();
-- return null;
-- })
-- .filter(Boolean);
-- return [...new Set(ids)].slice(0, 200).join(',');
-- })() : undefined,
-- viewer_auto_delete_enabled: req.body.viewer_auto_delete_enabled !== undefined
-- ? parseBoolean(req.body.viewer_auto_delete_enabled, true) : undefined,
-- viewer_delete_all_enabled: req.body.viewer_delete_all_enabled !== undefined
-- ? parseBoolean(req.body.viewer_delete_all_enabled, true) : undefined,
-- custom_emotes_enabled: req.body.custom_emotes_enabled !== undefined
-- ? parseBoolean(req.body.custom_emotes_enabled, true) : undefined,
-- custom_sounds_enabled: req.body.custom_sounds_enabled !== undefined
-- ? parseBoolean(req.body.custom_sounds_enabled, true) : undefined,
-- uploads_mods_only: req.body.uploads_mods_only !== undefined
-- ? parseBoolean(req.body.uploads_mods_only, false) : undefined,
-- mods_can_edit_about: req.body.mods_can_edit_about !== undefined
-- ? parseBoolean(req.body.mods_can_edit_about, false) : undefined,
-- max_sound_seconds: req.body.max_sound_seconds !== undefined
-- ? Math.min(30, Math.max(1, parseInt(req.body.max_sound_seconds) || 10)) : undefined,
-- emote_scale: req.body.emote_scale !== undefined
-- ? Math.min(300, Math.max(50, parseInt(req.body.emote_scale) || 100)) : undefined,
-- emote_size_min: req.body.emote_size_min !== undefined
-- ? Math.min(200, Math.max(25, parseInt(req.body.emote_size_min) || 50)) : undefined,
-- emote_size_max: req.body.emote_size_max !== undefined
-- ? Math.min(400, Math.max(50, parseInt(req.body.emote_size_max) || 200)) : undefined,
-- sounds_mods_only: req.body.sounds_mods_only !== undefined ? (req.body.sounds_mods_only ? 1 : 0) : undefined,
-- sound_min_speed: req.body.sound_min_speed !== undefined
-- ? Math.min(1, Math.max(0.1, parseFloat(req.body.sound_min_speed) || 0.5)) : undefined,
-- sound_max_speed: req.body.sound_max_speed !== undefined
-- ? Math.min(5, Math.max(1, parseFloat(req.body.sound_max_speed) || 3.0)) : undefined,
-- sound_min_pitch_cents: req.body.sound_min_pitch_cents !== undefined
-- ? Math.min(0, Math.max(-2400, parseInt(req.body.sound_min_pitch_cents) || -1200)) : undefined,
-- sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined
-- ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,
-- });
--
-- db.logModerationAction({
-- scope_type: 'channel',
-- scope_id: req.channel.id,
-- actor_user_id: req.user.id,
-- action_type: 'channel_settings_update',
-- details: settings,
-- });
--
-- res.json({ settings });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update moderation settings' });
-- }
--});
--
--// ── View moderation action log ───────────────────────────────
--router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {
-- try {
-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
-- res.json({
-- actions: db.getModerationActions({
-- scopeType: 'channel',
-- scopeId: req.channel.id,
-- limit,
-- offset,
-- }),
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load channel moderation log' });
-- }
--});
--
--// ── Search channel chat messages ─────────────────────────────
--router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {
-- try {
-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
-- const q = (req.query.q || '').trim();
-- const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;
-- const result = db.searchChannelChatMessages(req.channel.id, { query: q, userId, limit, offset });
-- res.json(result);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to search channel chat' });
-- }
--});
--
--// ── Delete a channel chat message ────────────────────────────
--router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {
-- try {
-- const messageId = parseInt(req.params.messageId, 10);
-- const message = db.getChatMessageById(messageId);
-- if (!message) return res.status(404).json({ error: 'Message not found' });
--
-- // Verify message belongs to this channel: one of its streams, or its offline channel room.
-- // Messages with no stream (global chat, other channels' offline rooms) and messages whose
-- // stream row is gone used to pass this check.
-- if (!permissions.isGlobalModOrAbove(req.user)) {
-- let inScope = false;
-- if (message.stream_id) {
-- const stream = db.getStreamById(message.stream_id);
-- inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));
-- } else if (message.channel_user_id) {
-- inScope = message.channel_user_id === req.channel.user_id;
-- }
-- if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });
-- }
--
-- db.deleteChatMessage(messageId, req.user.id);
--
-- // Broadcast the deletion to every surface that shows the line: the streamer's whole channel
-- // room (every live slot, the offline room, a channel popout) and the global feed.
-- const delPayload = { type: 'delete-messages', ids: [messageId] };
-- const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);
-- if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
-- if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
-- else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
-- else chatServer.broadcastGlobal(delPayload);
--
-- db.logModerationAction({
-- scope_type: 'channel',
-- scope_id: req.channel.id,
-- actor_user_id: req.user.id,
-- target_user_id: message.user_id,
-- action_type: 'channel_message_delete',
-- details: { message_id: messageId, stream_id: message.stream_id },
-- });
--
-- res.json({ message: 'Message deleted', ids: [messageId] });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete message' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex c36821a6..fae3bbfe 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -1,47 +1,30 @@
/**
-- * chat-ai-routes.js — read API for chat AI insight.
-- * GET /api/chat-ai/global → global chat overview + timeline + memory
-- * GET /api/chat-ai/user/:id → a user's "today vs all-time" insight + timeline
-- * Read-only; the summaries are produced by the chat-ai poller.
++ * chat-ai-routes.js — Live's /api/chat-ai reads.
++ *
++ * The chat-AI summaries (global, anon, relay, the global timeline) are OpenVibe.Chat's with the six chat
++ * tables (roadmap T3): Chat serves them at /api/chat/ai/*. What stays here is Live's own work, which
++ * folds Chat's chatter insight in where it needs it (server/chat/insight-client.js):
++ * GET /api/chat-ai/user/:id → { insight (Chat's), streamer (Live's overview + stream memories), user }
++ * GET /api/chat-ai/timeline/:username → a channel's AI timeline: the streamer overview, every session's AI
++ * overview and VOD moments, the chatter insight and a combined overview
++ * GET /api/chat-ai/live-captions/:username → the newest speech rows of the current live stream
++ * GET /api/chat-ai/vod-transcripts?ids=… → transcripts for a set of Media VOD ids
++ * GET /api/chat-ai/transcript/:streamId → the full transcript of a stream
++ * Read-only.
*/
'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db/database');
--const chatAi = require('./chat-ai');
++const insights = require('../chat/insight-client');
--router.get('/global', (req, res) => {
-- try {
-- const insight = chatAi.getGlobalInsight();
-- res.json({ insight: insight || null });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load global chat insight' });
-- }
--});
--
--// Browsable/searchable global timeline. Query: before=<ms>, since=<ms>, q=<search>, limit=<n>.
--router.get('/timeline', (req, res) => {
-- try {
-- const num = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; };
-- const limit = Math.min(60, Math.max(1, num(req.query.limit) || 25));
-- const events = db.getChatTimelineEvents({
-- scope: 'global', subjectId: 0,
-- before: num(req.query.before), since: num(req.query.since),
-- q: req.query.q || null, limit,
-- });
-- res.json({ events, hasMore: events.length >= limit });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load timeline' });
-- }
--});
--
--router.get('/user/:id', (req, res) => {
++router.get('/user/:id', async (req, res) => {
try {
const uid = parseInt(req.params.id, 10);
if (!Number.isFinite(uid)) return res.status(400).json({ error: 'Invalid user id' });
const user = db.getUserById ? db.getUserById(uid) : null;
-- const insight = chatAi.getUserInsight(uid);
++ const insight = await insights.getUser(uid);
// If this user is a streamer with an AI overview, lead the popover with who they
// are as a streamer (their overview + recent stream "memories" as context/timeline)
@@@ -79,33 +62,6 @@ router.get('/user/:id', (req, res) => {
}
});
--// An anonymous chatter's insight, keyed by their stable anon_id ("anon<N>").
--router.get('/anon/:anonId', (req, res) => {
-- try {
-- const anonId = String(req.params.anonId || '');
-- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
-- const insight = chatAi.getAnonInsight(anonId);
-- res.json({ insight: insight || null, user: { anon_id: anonId, username: anonId } });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load anon chat insight' });
-- }
--});
--
--// A bridged external (relay) chatter's insight, keyed by platform + username.
--router.get('/relay/:platform/:username', (req, res) => {
-- try {
-- const ru = db.getRelayUser(req.params.platform, req.params.username);
-- if (!ru) return res.json({ insight: null, user: null });
-- const insight = chatAi.getRelayUserInsight(ru.id);
-- res.json({
-- insight: insight || null,
-- user: { platform: ru.platform, username: ru.display_name || ru.username, message_count: ru.message_count || 0, first_seen: ru.first_seen || null },
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load relay chat insight' });
-- }
--});
--
// A single "whole person" overview fusing who they are as a streamer + as a chatter.
// Non-blocking: returns the cached synthesis if fresh, otherwise returns a quick fallback
// NOW and regenerates the AI synthesis in the background for next time (≤1 cheap LLM call
@@@ -200,7 +156,7 @@ router.get('/timeline/:username', async (req, res) => {
// "whole person" overview fusing streamer + chatter. Both are best-effort.
let chatInsight, combinedOverview;
if (first) {
-- try { chatInsight = chatAi.getUserInsight(user.id) || null; } catch { chatInsight = null; }
++ chatInsight = await insights.getUser(user.id);
try { combinedOverview = _combinedOverview(user.id, timeline.overview, chatInsight); } catch { combinedOverview = null; }
try { _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
}
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
ddeleted file mode 100644
iindex a31b9547..00000000
---- a/server/ai/chat-ai.js
++++ /dev/null
@@@ -1,476 +0,0 @@
--/**
-- * chat-ai.js — rolling AI insight for chat.
-- *
-- * Two products, one engine:
-- * • GLOBAL — an overview + timeline of ALL chat across the whole site (global
-- * room, every stream slot, every channel). Shown in the /chat header + panel.
-- * • PER-USER — a "today (24h) vs all-time" read on an individual chatter.
-- *
-- * Design for low cost + graceful scaling:
-- * • Pure DB poller (no hooks in the 8 saveChatMessage call sites). The high-water
-- * mark lives in `chat_ai_summaries.last_message_id`, so it survives restarts.
-- * • INCREMENTAL: each refresh folds only the recent window into a condensed running
-- * "memory" carried forward — one small LLM call per subject, cost is flat with volume.
-- * • ADAPTIVE window: the global overview covers however long it takes to gather ~300
-- * messages, clamped to [30 min, 14 days] — busy chat → short window, quiet → wide.
-- * • THROTTLED + CAPPED: min-interval between refreshes, a few subjects per tick, and
-- * everything gated by the shared `ai_enabled` switch + daily USD budget (the tick checks both;
-- * a run that does not answer is null, so we just skip).
-- */
--'use strict';
--
--const db = require('../db/database');
--// chat_ai_summaries and chat_timeline_events are staged chat tables: every write goes where the
--// table's authority is — Live's own table, or OpenVibe.Chat (chat/chat-tables.js).
--const chatTables = require('../chat/chat-tables');
--
--// ── Tunables ─────────────────────────────────────────────────────────────────
--const TICK_MS = 45 * 1000;
--
--const GLOBAL_MSG_THRESHOLD = 100; // refresh once this many new msgs pile up
--const GLOBAL_MIN_INTERVAL_MS = 5 * 60 * 1000; // never more often than this (flood guard)
--const GLOBAL_MAX_AGE_MS = 30 * 60 * 1000; // ...but refresh at least this often if any new
--
--const USER_MSG_THRESHOLD = 15; // per-user refresh trigger
--const USER_MAX_PER_TICK = 2; // cap LLM calls per tick
--const USER_PASS_EVERY_MS = 3 * 60 * 1000; // throttle the (heavier) user discovery scan
--const USER_STALE_MS = 24 * 60 * 60 * 1000; // refresh a lagging user at least daily
--const USER_DISCOVERY_LOOKBACK_DAYS = 14; // bound the discovery GROUP BY
--
--const WINDOW_TARGET_MESSAGES = 300; // adaptive-window sizing target
--const MAX_BATCH_MESSAGES = 300; // token cap per call
--const WINDOW_MIN_MS = 30 * 60 * 1000; // overview window is never shorter than 30 min
--const WINDOW_MAX_MS = 14 * 24 * 60 * 60 * 1000; // ...nor longer than 14 days
--const MEMORY_MAX_CHARS = 1600;
--const USER_MEMORY_MAX_CHARS = 1200;
--const TIMELINE_MAX = 40;
--const MSG_MAX_CHARS = 220;
--
--let _running = false;
--let _timer = null;
--let _tickInFlight = false;
--let _lastUserPass = 0;
--
--// ── Small utils ──────────────────────────────────────────────────────────────
--function _ai() { return require('./ai-analysis'); }
--function _aiService() { return require('./ai-service'); }
--
--// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.
--function _sqlTime(d) { return new Date(d).toISOString().slice(0, 19).replace('T', ' '); }
--function _parseSqlTime(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
--
--
--function _clip(str, n) { str = (str == null ? '' : String(str)).trim(); return str.length > n ? str.slice(0, n) : str; }
--
--
--/** Messages as data for OpenVibe.AI's chat templates (WS-O task 2), which format them "[Xm ago] [#channel] name: text". */
--function _msgData(rows, { includeChannel = false, now = 0 } = {}) {
-- return rows.slice(-MAX_BATCH_MESSAGES).map((r) => {
-- let where = null;
-- if (includeChannel) where = r.is_global ? 'global' : r.channel_username ? `#${r.channel_username}` : r.stream_id ? 'stream' : null;
-- const t = now ? _parseSqlTime(r.timestamp || r.created_at) : 0;
-- return {
-- mins_ago: t ? Math.max(0, Math.round((now - t) / 60000)) : null, where: where ? String(where).slice(0, 80) : null,
-- author: String(r.username || (r.user_id ? `user#${r.user_id}` : 'anon')).slice(0, 80),
-- kind: r.message_type && r.message_type !== 'chat' ? String(r.message_type).slice(0, 40) : null, text: _clip(r.message, MSG_MAX_CHARS),
-- };
-- });
--}
--
--function _windowLabel(ms) {
-- const min = Math.round(ms / 60000);
-- if (min < 90) return min <= 60 ? 'past hour' : `past ${min} min`;
-- const hrs = Math.round(ms / 3600000);
-- if (hrs < 36) return `past ${hrs} hours`;
-- const days = Math.max(1, Math.round(ms / 86400000));
-- return `past ${days} day${days === 1 ? '' : 's'}`;
--}
--
--// Stamp raw model additions into {ts,label,detail}, skipping placeholders, timestamping each
--// with the AI's mins_ago (when it happened) or the fallback.
--function _stampAdditions(additions, fallbackTs, nowMs) {
-- const now = nowMs || Date.now();
-- const out = [];
-- for (const a of (Array.isArray(additions) ? additions : [])) {
-- const label = _clip(a && (a.label || a.title), 80);
-- if (!label || _isPlaceholder(label)) continue;
-- let ts = fallbackTs;
-- const mins = Number(a && a.mins_ago);
-- if (Number.isFinite(mins) && mins >= 0 && mins <= 43200) ts = _sqlTime(now - mins * 60000);
-- out.push({ ts, label, detail: _clip(a.detail || a.description || '', 240) });
-- }
-- return out;
--}
--function _mergeTimeline(priorJson, additions, fallbackTs, nowMs) {
-- let prior = [];
-- try { prior = JSON.parse(priorJson || '[]'); if (!Array.isArray(prior)) prior = []; } catch { prior = []; }
-- prior.push(..._stampAdditions(additions, fallbackTs, nowMs));
-- // Keep chronological (mins_ago stamps mean append order isn't time order) then cap to newest.
-- prior.sort((x, y) => _parseSqlTime(x.ts) - _parseSqlTime(y.ts));
-- if (prior.length > TIMELINE_MAX) prior = prior.slice(prior.length - TIMELINE_MAX);
-- return JSON.stringify(prior);
--}
--// The model sometimes echoes the schema's example text as a real entry — reject those.
--function _isPlaceholder(s) { return /^(short title|one sentence|label|title|detail|\.\.\.)$/i.test(String(s || '').trim()); }
--function _cleanTimeline(arr) {
-- if (!Array.isArray(arr)) return [];
-- return arr
-- .filter(t => t && t.label && !_isPlaceholder(t.label))
-- .sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts)); // chronological; UI reverses for newest-first
--}
--
--// ── GLOBAL ───────────────────────────────────────────────────────────────────
--async function _refreshGlobal() {
-- const ai = _ai();
-- const prior = db.getChatAiSummary('global', 0, 'global');
-- const hw = prior ? (prior.last_message_id || 0) : 0;
-- const maxId = db.getMaxChatMessageId();
-- const newCount = maxId > hw ? db.countChatMessagesSince(hw) : 0;
-- if (newCount === 0) return false;
--
-- const ageMs = prior && prior.updated_at ? (Date.now() - _parseSqlTime(prior.updated_at)) : Infinity;
-- if (ageMs < GLOBAL_MIN_INTERVAL_MS) return false;
-- if (newCount < GLOBAL_MSG_THRESHOLD && ageMs < GLOBAL_MAX_AGE_MS) return false;
--
-- // Adaptive window: back to the ~300th-most-recent message, clamped to [30min, 14d].
-- const now = Date.now();
-- const nthTs = db.getNthRecentChatTs(WINDOW_TARGET_MESSAGES);
-- let startMs = nthTs ? _parseSqlTime(nthTs) : (now - WINDOW_MAX_MS);
-- startMs = Math.min(startMs, now - WINDOW_MIN_MS);
-- startMs = Math.max(startMs, now - WINDOW_MAX_MS);
-- const windowStart = _sqlTime(startMs);
-- const windowLabel = _windowLabel(now - startMs);
--
-- const rows = db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
-- if (!rows.length) return false;
-- const priorMemory = prior ? (prior.memory_json || '') : '';
-- let priorTl = [];
-- try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
-- // The prompt is OpenVibe.AI's versioned template live.chat.global (WS-O task 2); Live sends the chat as data.
-- const parsed = await _aiService().structured('live.chat.global', {
-- window_label: windowLabel, prior_memory: _clip(priorMemory, MEMORY_MAX_CHARS),
-- recent_labels: priorTl.slice(-8).map(t => _clip(t.label, 200)).filter(Boolean),
-- messages: _msgData(rows, { includeChannel: true, now }),
-- }, { meter: { kind: 'chat_global', role: 'legacy' } });
-- if (!parsed) return false; // disabled / over budget / call failed
--
-- const nowIso = _sqlTime(now);
-- // Persist the new moments to the growing timeline log (for the browsable/searchable view).
-- try { await chatTables.write('addChatTimelineEvents', 'global', 0, _stampAdditions(parsed.timeline, nowIso, now)); } catch { /* */ }
-- await chatTables.write('upsertChatAiSummary', {
-- scope: 'global', subject_id: 0, window: 'global',
-- overview: _clip(parsed.recent_overview || '', 2000),
-- memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
-- timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
-- message_count: (prior ? (prior.message_count || 0) : 0) + newCount,
-- window_message_count: rows.length,
-- last_message_id: maxId,
-- window_label: windowLabel,
-- window_start: windowStart,
-- window_end: nowIso,
-- });
-- console.log(`[ChatAI] global refreshed (${newCount} new, window=${windowLabel}, ${rows.length} msgs)`);
-- return true;
--}
--
--// ── PER-USER ─────────────────────────────────────────────────────────────────
--async function _refreshUser(uid, maxId) {
-- const ai = _ai();
-- const prior = db.getChatAiSummary('user', uid, 'rolling');
-- const now = Date.now();
--
-- // Last-24h messages for the "today" read; fall back to most-recent if quiet in 24h.
-- const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
-- let dayRows = db.getChatMessagesForAi({ sinceTs: dayStart, userId: uid, order: 'asc', limit: MAX_BATCH_MESSAGES });
-- let has24h = dayRows.length > 0;
-- if (!dayRows.length) dayRows = db.getChatMessagesForAi({ userId: uid, order: 'desc', limit: 80 });
-- if (!dayRows.length) return false;
--
-- const uname = dayRows[dayRows.length - 1].username || `user#${uid}`;
-- const priorMemory = prior ? (prior.memory_json || '') : '';
-- const totalSeen = (prior ? (prior.message_count || 0) : 0);
--
-- const parsed = await _aiService().structured('live.chat.profile', {
-- subject_kind: 'user', name: _clip(uname, 120), recent_24h: has24h, seen: totalSeen, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
-- messages: _msgData(dayRows, { now }),
-- }, { meter: { kind: 'chat_user', role: 'legacy' } });
-- if (!parsed) return false;
--
-- const newCount = db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);
-- const nowIso = _sqlTime(now);
-- // Stamp new timeline moments with the user's ACTUAL last activity time in the analyzed
-- // batch — NOT "now". Otherwise old moments show as "2m ago" whenever the insight is refreshed
-- // (e.g. when someone opens it), even if the user hasn't chatted in days.
-- const _lastRow = dayRows[dayRows.length - 1] || {};
-- const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;
-- // Canonical rolling row: memory + timeline + high-water + both overviews (as JSON).
-- await chatTables.write('upsertChatAiSummary', {
-- scope: 'user', subject_id: uid, window: 'rolling',
-- overview: JSON.stringify({
-- today: _clip(parsed.overview_24h || '', 1200),
-- alltime: _clip(parsed.overview_alltime || '', 1200),
-- has_24h: has24h,
-- }),
-- memory_json: _clip(parsed.memory || priorMemory, USER_MEMORY_MAX_CHARS),
-- timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, activityTs, now),
-- message_count: totalSeen + newCount,
-- window_message_count: dayRows.length,
-- last_message_id: maxId || db.getMaxChatMessageId(),
-- window_label: has24h ? 'past 24h' : 'recent',
-- window_start: has24h ? dayStart : null,
-- window_end: nowIso,
-- });
-- console.log(`[ChatAI] user ${uid} (${uname}) refreshed (${newCount} new, 24h=${has24h})`);
-- return true;
--}
--
--async function _userPass() {
-- const staleCutoff = _sqlTime(Date.now() - USER_STALE_MS);
-- const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
-- let candidates = [];
-- try {
-- candidates = db.getUsersNeedingChatAi({
-- threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK,
-- });
-- } catch (e) { console.warn('[ChatAI] user discovery failed:', e.message); return; }
-- for (const c of candidates) {
-- if (!c.uid) continue;
-- try { await _refreshUser(c.uid, c.max_id); }
-- catch (e) { console.warn(`[ChatAI] user ${c.uid} refresh failed:`, e.message); }
-- }
--}
--
--// ── PER RELAY-USER (external platform chatters bridged in) ────────────────────
--// Same "today vs all-time" insight as a native user, keyed by the relay_users rowid.
--async function _refreshRelayUser(ru) {
-- const ai = _ai();
-- const prior = db.getChatAiSummary('relay', ru.id, 'rolling');
-- const now = Date.now();
-- const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
--
-- let dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
-- let has24h = dayRows.length > 0;
-- if (!dayRows.length) dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, order: 'desc', limit: 80 });
-- if (!dayRows.length) return false;
--
-- const uname = ru.display_name || ru.username;
-- const priorMemory = prior ? (prior.memory_json || '') : '';
--
-- const parsed = await _aiService().structured('live.chat.profile', {
-- subject_kind: 'relay', name: _clip(uname, 120), platform: _clip(ru.platform, 40), recent_24h: has24h, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
-- messages: _msgData(dayRows, { now }),
-- }, { meter: { kind: 'chat_relay', role: 'legacy' } });
-- if (!parsed) return false;
--
-- const nowIso = _sqlTime(now);
-- await chatTables.write('upsertChatAiSummary', {
-- scope: 'relay', subject_id: ru.id, window: 'rolling',
-- overview: JSON.stringify({
-- today: _clip(parsed.overview_24h || '', 1200),
-- alltime: _clip(parsed.overview_alltime || '', 1200),
-- has_24h: has24h,
-- }),
-- memory_json: _clip(parsed.memory || priorMemory, USER_MEMORY_MAX_CHARS),
-- timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
-- message_count: ru.message_count || 0,
-- window_message_count: dayRows.length,
-- last_message_id: db.getMaxChatMessageId(),
-- window_label: has24h ? 'past 24h' : 'recent',
-- window_start: has24h ? dayStart : null,
-- window_end: nowIso,
-- });
-- console.log(`[ChatAI] relay ${ru.platform}:${ru.username} refreshed (24h=${has24h}, ${dayRows.length} msgs)`);
-- return true;
--}
--
--async function _relayPass() {
-- const lookbackIso = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
-- let candidates = [];
-- try { candidates = db.getRelayUsersNeedingChatAi({ lookbackIso, threshold: 8, limit: USER_MAX_PER_TICK }); }
-- catch (e) { console.warn('[ChatAI] relay discovery failed:', e.message); return; }
-- for (const ru of candidates) {
-- if (!ru.id) continue;
-- try { await _refreshRelayUser(ru); }
-- catch (e) { console.warn(`[ChatAI] relay ${ru.id} refresh failed:`, e.message); }
-- }
--}
--
--// ── PER ANON (not-logged-in chatters, keyed by their stable anon_id) ──────────
--// Same "today vs all-time" insight as a native user, keyed by the numeric anon id.
--async function _refreshAnon(anonId) {
-- const ai = _ai();
-- const subjectId = db.anonSubjectId(anonId);
-- if (!subjectId) return false;
-- const prior = db.getChatAiSummary('anon', subjectId, 'rolling');
-- const now = Date.now();
-- const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
--
-- let dayRows = db.getAnonChatMessagesForAi({ anonId, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
-- let has24h = dayRows.length > 0;
-- if (!dayRows.length) dayRows = db.getAnonChatMessagesForAi({ anonId, order: 'desc', limit: 80 });
-- if (!dayRows.length) return false;
--
-- const priorMemory = prior ? (prior.memory_json || '') : '';
-- const parsed = await _aiService().structured('live.chat.profile', {
-- subject_kind: 'anon', name: _clip(anonId, 120), recent_24h: has24h, prior_memory: _clip(priorMemory, USER_MEMORY_MAX_CHARS),
-- messages: _msgData(dayRows, { now }),
-- }, { meter: { kind: 'chat_anon', role: 'legacy' } });
-- if (!parsed) return false;
--
-- const nowIso = _sqlTime(now);
-- await chatTables.write('upsertChatAiSummary', {
-- scope: 'anon', subject_id: subjectId, window: 'rolling',
-- overview: JSON.stringify({
-- today: _clip(parsed.overview_24h || '', 1200),
-- alltime: _clip(parsed.overview_alltime || '', 1200),
-- has_24h: has24h,
-- }),
-- memory_json: _clip(parsed.memory || priorMemory, USER_MEMORY_MAX_CHARS),
-- timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
-- message_count: dayRows.length,
-- window_message_count: dayRows.length,
-- last_message_id: db.getMaxChatMessageId(),
-- window_label: has24h ? 'past 24h' : 'recent',
-- window_start: has24h ? dayStart : null,
-- window_end: nowIso,
-- });
-- console.log(`[ChatAI] anon ${anonId} refreshed (24h=${has24h}, ${dayRows.length} msgs)`);
-- return true;
--}
--
--async function _anonPass() {
-- const staleCutoff = _sqlTime(Date.now() - USER_STALE_MS);
-- const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
-- let candidates = [];
-- try {
-- candidates = db.getAnonsNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });
-- } catch (e) { console.warn('[ChatAI] anon discovery failed:', e.message); return; }
-- for (const c of candidates) {
-- if (!c.anon_id) continue;
-- try { await _refreshAnon(c.anon_id); }
-- catch (e) { console.warn(`[ChatAI] anon ${c.anon_id} refresh failed:`, e.message); }
-- }
--}
--
--// ── Poller ───────────────────────────────────────────────────────────────────
--async function _tick() {
-- if (_tickInFlight) return;
-- _tickInFlight = true;
-- try {
-- const ai = _ai();
-- if (!ai.isEnabled() || !ai.withinBudget()) return; // no key / disabled / over daily cap
-- try { await _refreshGlobal(); }
-- catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }
--
-- if (Date.now() - _lastUserPass >= USER_PASS_EVERY_MS) {
-- _lastUserPass = Date.now();
-- if (ai.withinBudget()) await _userPass();
-- if (ai.withinBudget()) await _relayPass();
-- if (ai.withinBudget()) await _anonPass();
-- }
-- } catch (e) {
-- console.warn('[ChatAI] tick error:', e.message);
-- } finally {
-- _tickInFlight = false;
-- }
--}
--
--// One-time: backfill the growing timeline log from the existing summary JSON so the browsable
--// view has history immediately (before new moments accumulate).
--async function _seedTimelineEvents() {
-- try {
-- if ((db.getChatTimelineEvents({ scope: 'global', limit: 1 }) || []).length) return;
-- const row = db.getChatAiSummary('global', 0, 'global');
-- if (!row) return;
-- let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }
-- const cleaned = _cleanTimeline(tl);
-- if (cleaned.length) { await chatTables.write('addChatTimelineEvents', 'global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }
-- } catch { /* */ }
--}
--
--function start() {
-- if (_running) return;
-- _running = true;
-- _seedTimelineEvents().catch(() => {});
-- _timer = setInterval(() => { _tick().catch(() => {}); }, TICK_MS);
-- if (_timer.unref) _timer.unref();
-- console.log('[AI] Chat-AI job started (global overview/timeline + per-user insights)');
--}
--
--function stop() {
-- _running = false;
-- if (_timer) { clearInterval(_timer); _timer = null; }
--}
--
--// Public read helpers (used by the routes) — parse stored JSON into a clean shape.
--function getGlobalInsight() {
-- const row = db.getChatAiSummary('global', 0, 'global');
-- if (!row) return null;
-- let timeline = [];
-- try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
-- return {
-- overview: row.overview || '',
-- memory: row.memory_json || '',
-- timeline,
-- window_label: row.window_label || '',
-- message_count: row.message_count || 0,
-- window_message_count: row.window_message_count || 0,
-- updated_at: row.updated_at || null,
-- };
--}
--
--function getUserInsight(userId) {
-- const row = db.getChatAiSummary('user', userId, 'rolling');
-- if (!row) return null;
-- let overviews = { today: '', alltime: '', has_24h: false };
-- try { overviews = { ...overviews, ...JSON.parse(row.overview || '{}') }; } catch { /* */ }
-- let timeline = [];
-- try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
-- return {
-- overview_24h: overviews.today || '',
-- overview_alltime: overviews.alltime || '',
-- has_24h: !!overviews.has_24h,
-- memory: row.memory_json || '',
-- timeline,
-- message_count: row.message_count || 0,
-- updated_at: row.updated_at || null,
-- };
--}
--
--function getRelayUserInsight(relayId) {
-- const row = db.getChatAiSummary('relay', relayId, 'rolling');
-- if (!row) return null;
-- let overviews = { today: '', alltime: '', has_24h: false };
-- try { overviews = { ...overviews, ...JSON.parse(row.overview || '{}') }; } catch { /* */ }
-- let timeline = [];
-- try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
-- return {
-- overview_24h: overviews.today || '',
-- overview_alltime: overviews.alltime || '',
-- has_24h: !!overviews.has_24h,
-- memory: row.memory_json || '',
-- timeline,
-- message_count: row.message_count || 0,
-- updated_at: row.updated_at || null,
-- };
--}
--
--function getAnonInsight(anonId) {
-- const row = db.getChatAiSummary('anon', db.anonSubjectId(anonId), 'rolling');
-- if (!row) return null;
-- let overviews = { today: '', alltime: '', has_24h: false };
-- try { overviews = { ...overviews, ...JSON.parse(row.overview || '{}') }; } catch { /* */ }
-- let timeline = [];
-- try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
-- return {
-- overview_24h: overviews.today || '',
-- overview_alltime: overviews.alltime || '',
-- has_24h: !!overviews.has_24h,
-- memory: row.memory_json || '',
-- timeline,
-- message_count: row.message_count || 0,
-- updated_at: row.updated_at || null,
-- };
--}
--
--module.exports = { start, stop, getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight, _tick, _msgData };
ddiff --git a/server/ai/context/index.js b/server/ai/context/index.js
iindex 09022bcb..4543ae65 100644
---- a/server/ai/context/index.js
++++ b/server/ai/context/index.js
@@@ -4,7 +4,7 @@
* Nothing here runs a new analysis. It reads the stores other jobs keep warm:
* 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_ai_summaries (global/user/relay/anon)
++ * app_state ai_whole_overview_<uid> (combined blurb)
* chat_messages (channel + relayed + bot lines) follows / subscriptions / stream_first_chats
* channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)
* ai_viewer_threads (open conversations)
@@@ -22,6 +22,7 @@
'use strict';
const crypto = require('crypto');
const db = require('../../db/database');
++const insightClient = require('../../chat/insight-client');
function clip(str, n) { return (str == null ? '' : String(str)).replace(/\s+/g, ' ').trim().slice(0, n); }
function clipWords(str, n) { const t = clip(str, n * 8); return t.length > n ? t.slice(0, n).replace(/\s+\S*$/, '') + '…' : t; }
@@@ -174,23 +175,22 @@ function chatDelta(channelUserId, sinceId, { limit = 40, botNames = new Set() }
return { lines, text: text.length > 2500 ? text.slice(-2500) : text, maxId: rows.length ? rows[rows.length - 1].id : sinceId || 0 };
}
++// The global chat-AI overview and each chatter's insight are OpenVibe.Chat's (roadmap T3): read through
++// server/chat/insight-client.js, whose peeks answer what is cached and warm it for the next tick.
function moodBlock() {
-- try { const g = require('../chat-ai').getGlobalInsight(); return g && g.overview ? clip(g.overview, 250) : ''; } catch { return ''; }
++ const g = insightClient.peekGlobal();
++ return g && g.overview ? clip(g.overview, 250) : '';
}
function personBlock(line, channelUserId, settings) {
if (!settings.remember_viewers || line.isBot) return '';
-- const chatAi = require('../chat-ai');
let ins = null;
-- try {
-- if (line.userId) ins = chatAi.getUserInsight(line.userId);
-- else if (line.anonId) ins = chatAi.getAnonInsight(line.anonId);
-- else if (line.platform && !line.userId) {
-- const m = String(line.username || '').match(/^\[[^\]]+\]\s*(.+)$/);
-- const ru = db.getRelayUser(line.platform, m ? m[1] : line.username);
-- if (ru) ins = chatAi.getRelayUserInsight(ru.id);
-- }
-- } catch { ins = null; }
++ if (line.userId) ins = insightClient.peekUser(line.userId);
++ else if (line.anonId) ins = insightClient.peekAnon(line.anonId);
++ else if (line.platform) {
++ const m = String(line.username || '').match(/^\[[^\]]+\]\s*(.+)$/);
++ ins = insightClient.peekRelay(line.platform, m ? m[1] : line.username);
++ }
const flags = [];
try {
if (line.userId) {
ddiff --git a/server/ai/easter-egg-job.js b/server/ai/easter-egg-job.js
iindex 346caae0..e709adf3 100644
---- a/server/ai/easter-egg-job.js
++++ b/server/ai/easter-egg-job.js
@@@ -9,7 +9,6 @@
const db = require('../db/database');
const ai = require('./ai-analysis');
const aiService = require('./ai-service');
--let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* */ }
const SETTING = 'daily_easter_egg';
const DIRS = ['up', 'down', 'left', 'right'];
@@@ -95,8 +94,9 @@ function _alignClues(code, aiClues) {
}
async function _generate() {
++ // The chat's current vibe: OpenVibe.Chat's global chat-AI overview (roadmap T3).
let vibe = '';
-- try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
++ try { const g = await require('../chat/insight-client').getGlobal(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
// The prompt and its clue rules are OpenVibe.AI's versioned template live.easter_egg (WS-O task 2).
try {
ddiff --git a/server/ai/slogan-job.js b/server/ai/slogan-job.js
iindex e7c02cb8..163abc46 100644
---- a/server/ai/slogan-job.js
++++ b/server/ai/slogan-job.js
@@@ -13,7 +13,6 @@
const db = require('../db/database');
const aiService = require('./ai-service');
const ai = require('./ai-analysis');
--let chatAi = null; try { chatAi = require('./chat-ai'); } catch { /* optional */ }
// Slogans are driven together with the hero background moments (ai-moments-job triggers a
// regen every 6h). This is only a FALLBACK cadence — slightly longer than the moments' 6h so
@@@ -65,9 +64,11 @@ async function tick() {
_busy = true;
try {
// ── Global chat AI: overview + running memory + timeline ──
++ // The global chat-AI summary is OpenVibe.Chat's (roadmap T3), read through its public API.
++ const insights = require('../chat/insight-client');
let global = '';
try {
-- const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight();
++ const g = await insights.getGlobal();
if (g) {
const tl = g.timeline ? (typeof g.timeline === 'string' ? g.timeline : JSON.stringify(g.timeline)) : '';
global = [g.overview, g.memory, tl && `Timeline: ${tl}`].filter(Boolean).join('\n').slice(0, 1800);
@@@ -84,16 +85,14 @@ async function tick() {
`) || [];
} catch { /* */ }
const usernames = activeRows.map(r => r.username).filter(Boolean);
-- // The prompt is OpenVibe.AI's versioned template live.hero.slogans (WS-O task 2); Live sends the real data.
++ // The prompt is OpenVibe.AI's versioned template live.hero.slogans (WS-O task 2); Live sends the
++ // real data. Per-chatter chat-AI blurbs come from OpenVibe.Chat (roadmap T3).
const users = [];
-- try {
-- for (const r of activeRows.slice(0, 8)) {
-- let ins = null;
-- try { ins = chatAi && chatAi.getUserInsight && chatAi.getUserInsight(r.id); } catch { /* */ }
-- const blurb = ins && (ins.overview || ins.memory);
-- if (blurb) users.push({ name: String(r.username).slice(0, 120), text: String(blurb).replace(/\s+/g, ' ').slice(0, 180) });
-- }
-- } catch { /* */ }
++ for (const r of activeRows.slice(0, 8)) {
++ const ins = await insights.getUser(r.id).catch(() => null);
++ const blurb = ins && (ins.overview || ins.memory);
++ if (blurb) users.push({ name: String(r.username).slice(0, 120), text: String(blurb).replace(/\s+/g, ' ').slice(0, 180) });
++ }
// ── Streamer AI overviews + recent VOD AI overviews ──
let streamers = [];
ddiff --git a/server/ai/viewers/routes.js b/server/ai/viewers/routes.js
iindex 35b5f105..df25ac6e 100644
---- a/server/ai/viewers/routes.js
++++ b/server/ai/viewers/routes.js
@@@ -15,7 +15,6 @@ const express = require('express');
const db = require('../../db/database');
const { requireAuth } = require('../../auth/auth');
const { isStaff } = require('../../auth/permissions');
--const chatAi = require('../chat-ai');
const budget = require('./budget');
const roster = require('./roster');
const { clearBrain } = require('./fold');
@@@ -257,12 +256,13 @@ router.post('/clone', requireAuth, async (req, res) => {
const u = userId ? db.getUserById(userId) : null;
if (!u) return res.status(404).json({ error: 'User not found' });
// Staff may clone anyone; a streamer only from what that person said in their own
-- // channel (the site-wide history and AI insight describe other channels too).
++ // channel (the site-wide history and the chatter's chat-AI insight, which OpenVibe.Chat
++ // keeps, describe other channels too).
const wide = isStaff(req.user) || userId === req.user.id;
src = {
kind: 'user', ref: String(userId),
displayName: u.display_name || u.username,
-- insight: wide ? chatAi.getUserInsight(userId) : null,
++ insight: wide ? await require('../../chat/insight-client').getUser(userId) : null,
samples: wide ? (db.getUserChatHistory(userId, 30).messages || [])
: db.getChatSamplesInChannel(req.user.id, { userId, limit: 30 }),
};
@@@ -277,7 +277,7 @@ router.post('/clone', requireAuth, async (req, res) => {
src = {
kind: 'relay', ref: `${platform}:${username}`,
displayName: username,
-- insight: relay && wide ? chatAi.getRelayUserInsight(relay.id) : null,
++ insight: wide ? await require('../../chat/insight-client').getRelay(platform, username) : null,
samples: wide ? (db.getRelayUserChatHistory(platform, username, { limit: 30 }).messages || [])
: db.getChatSamplesInChannel(req.user.id, { relay: { platform, rawUsername: username }, limit: 30 }),
};
ddiff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js
iindex 76f98a33..eb9c4008 100644
---- a/server/chat/chat-remote.js
++++ b/server/chat/chat-remote.js
@@@ -60,13 +60,10 @@ const FORWARDED_DB = {
updateChannelSoundEmoteRefs: 'local',
};
--// Live-owned data Chat caches, written by Live's own routes (dashboard, /api/channels, /api/mod):
--// after the write, Chat is told to reload it instead of waiting for its cache to expire.
++// Live-owned data Chat caches, written by Live's own routes (IP approvals, bans): after the write,
++// Chat is told to reload it instead of waiting for its cache to expire. Channel moderators, channel
++// moderation settings and alert sounds are Chat's own tables since roadmap T3, so nothing here.
const OBSERVED_DB = {
-- addChannelModerator: (a) => ['channel', a[0]],
-- removeChannelModerator: (a) => ['channel', a[0]],
-- upsertChannelModerationSettings: (a) => ['channel', a[0]],
-- setChannelAlertSound: (a) => ['channel', a[0]],
approveIp: (a) => ['approvals', a[0]],
revokeIpApproval: (a) => ['approvals', a[0]],
forgiveBan: () => ['bans', null],
ddiff --git a/server/chat/chat-tables-sync.js b/server/chat/chat-tables-sync.js
ddeleted file mode 100644
iindex 24adbe84..00000000
---- a/server/chat/chat-tables-sync.js
++++ /dev/null
@@@ -1,325 +0,0 @@
--'use strict';
--/**
-- * Keeping OpenVibe.Chat's copy of the staged tables current while Live writes them, and checking it
-- * (roadmap C-04; chat-tables.js says who writes what). Started by chat-tables.init() only.
-- *
-- * Capture. TEMP triggers on Live's connection record every insert, update and delete on a staged
-- * table in chat_staged_outbox, while the table is listed in temp.chat_staged_capture — the tables Live
-- * writes. Rows Chat's mirror applies to a table Chat writes are never captured.
-- *
-- * Relay. Every 2 s the outbox goes to Chat's bridge as `stagedApply` — each row as it is now (so
-- * repeated changes to a row collapse into one), in the order of their last change — and leaves the
-- * outbox once Chat has acknowledged it. drain(table) empties one table's part before a handoff.
-- * Paused (app_state `chat_table_relay_paused`, POST /internal/chat-tables/relay) the capture goes on
-- * and nothing is sent: pause, snapshot live.db, import the snapshot into Chat, resume — every change
-- * made after the pause then reaches Chat after the import, so the import never undoes one.
-- *
-- * Dual read. With a table's flag on, Live's reads of it (the database.js readers below, and
-- * tags.getUserTags) also ask Chat for the same slice — the rows whose columns equal the read's keys —
-- * and compare row count and content hash (Chat's database.js sliceHash, computed the same way here).
-- * After the read, in the background, at most once a minute per slice and 30 times a minute in all;
-- * inconclusive (not counted as a mismatch) while the table still has changes on their way to Chat
-- * or when Live's own rows changed during the comparison. Counts are kept in chat_dual_read_stats.
-- * Live's answer is never changed.
-- */
--const crypto = require('crypto');
--const db = require('../db/database');
--const chatTables = require('./chat-tables');
--
--const BATCH = 200;
--const RELAY_MS = 2000;
--const SLICE_EVERY_MS = 60_000;
--const COMPARES_PER_MIN = 30;
--
--const relay = { sent: 0, failed: 0, lastError: null, lastSentAt: null };
--let started = false;
--const PAUSE_KEY = 'chat_table_relay_paused';
--
--function paused() { try { return db.getState(PAUSE_KEY) === '1'; } catch { return false; } }
--function setPaused(on) {
-- if (on) db.setState(PAUSE_KEY, '1'); else db.deleteState(PAUSE_KEY);
-- return paused();
--}
--
--function ensureTables() {
-- db.getDb().exec(`
-- CREATE TABLE IF NOT EXISTS chat_staged_outbox (
-- seq INTEGER PRIMARY KEY AUTOINCREMENT,
-- tbl TEXT NOT NULL,
-- op TEXT NOT NULL,
-- pk TEXT NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
-- CREATE TABLE IF NOT EXISTS chat_dual_read_stats (
-- tbl TEXT PRIMARY KEY,
-- compared INTEGER NOT NULL DEFAULT 0,
-- matched INTEGER NOT NULL DEFAULT 0,
-- mismatched INTEGER NOT NULL DEFAULT 0,
-- inconclusive INTEGER NOT NULL DEFAULT 0,
-- errors INTEGER NOT NULL DEFAULT 0,
-- last_mismatch_at DATETIME,
-- last_mismatch TEXT,
-- since DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
-- `);
--}
--
--// ── Capture ─────────────────────────────────────────────────────
--
--function installCapture() {
-- const d = db.getDb();
-- d.exec('CREATE TEMP TABLE IF NOT EXISTS chat_staged_capture (tbl TEXT PRIMARY KEY)');
-- for (const [table, pk] of Object.entries(chatTables.TABLES)) {
-- const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;
-- const when = `WHEN EXISTS (SELECT 1 FROM temp.chat_staged_capture WHERE tbl = '${table}')`;
-- try {
-- d.exec(`
-- CREATE TEMP TRIGGER IF NOT EXISTS chat_staged_${table}_ins AFTER INSERT ON main.${table} ${when}
-- BEGIN INSERT INTO chat_staged_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
-- CREATE TEMP TRIGGER IF NOT EXISTS chat_staged_${table}_upd AFTER UPDATE ON main.${table} ${when}
-- BEGIN INSERT INTO chat_staged_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
-- CREATE TEMP TRIGGER IF NOT EXISTS chat_staged_${table}_del AFTER DELETE ON main.${table} ${when}
-- BEGIN INSERT INTO chat_staged_outbox (tbl, op, pk) VALUES ('${table}', 'delete', ${obj('OLD')}); END;
-- `);
-- } catch (err) { console.warn(`[ChatTables] capture for ${table}: ${err.message}`); }
-- setCapture(table, chatTables.authority(table) === 'live');
-- }
--}
--
--function setCapture(table, on) {
-- const d = db.getDb();
-- if (on) d.prepare('INSERT OR IGNORE INTO temp.chat_staged_capture (tbl) VALUES (?)').run(table);
-- else d.prepare('DELETE FROM temp.chat_staged_capture WHERE tbl = ?').run(table);
--}
--
--function pending(table = null) {
-- try {
-- return table
-- ? db.get('SELECT COUNT(*) AS n FROM chat_staged_outbox WHERE tbl = ?', [table]).n
-- : db.get('SELECT COUNT(*) AS n FROM chat_staged_outbox').n;
-- } catch { return 0; } // no outbox: nothing was ever captured
--}
--
--// ── Relay ───────────────────────────────────────────────────────
--
--/** The outbox rows as changes: each row as it is now, ordered by its last change. */
--function buildChanges(rows) {
-- const last = new Map();
-- for (const r of rows) last.set(`${r.tbl}|${r.pk}`, r);
-- const changes = [];
-- for (const r of [...last.values()].sort((a, b) => a.seq - b.seq)) {
-- const cols = chatTables.TABLES[r.tbl];
-- if (!cols) continue;
-- let pk;
-- try { pk = JSON.parse(r.pk); } catch { continue; }
-- const row = r.op === 'delete' ? null : db.get(`SELECT * FROM ${r.tbl} WHERE ${cols.map((c) => `${c} = ?`).join(' AND ')}`, cols.map((c) => pk[c]));
-- changes.push(row ? { table: r.tbl, op: 'upsert', row } : { table: r.tbl, op: 'delete', pk });
-- }
-- return changes;
--}
--
--let busy = null;
--/** Send the outbox (or only one table's rows) to Chat. → { sent, pending, error } */
--function flush(table = null) {
-- if (busy) return busy.then(() => flush(table));
-- if (paused()) return Promise.resolve({ sent: 0, pending: pending(table), error: 'relay paused' });
-- busy = (async () => {
-- let sent = 0;
-- for (;;) {
-- const rows = table
-- ? db.all('SELECT seq, tbl, op, pk FROM chat_staged_outbox WHERE tbl = ? ORDER BY seq LIMIT ?', [table, BATCH])
-- : db.all('SELECT seq, tbl, op, pk FROM chat_staged_outbox ORDER BY seq LIMIT ?', [BATCH]);
-- if (!rows.length) break;
-- const changes = buildChanges(rows);
-- let out;
-- try {
-- out = await chatTables.callChat('stagedApply', [changes]);
-- } catch (err) {
-- relay.failed++;
-- if (relay.lastError !== err.message) console.warn(`[ChatTables] relay to Chat waiting (${pending()} queued): ${err.message}`);
-- relay.lastError = err.message;
-- break;
-- }
-- // Chat refusing a change means it thinks it writes that table: keep those rows (never lose
-- // Live's write), report it, and stop until an operator makes both sides agree.
-- const refused = new Set((out.skipped || []).filter((s) => /table_authority chat/.test(s.reason || '')).map((s) => s.table));
-- const done = rows.filter((r) => !refused.has(r.tbl)).map((r) => r.seq);
-- for (let i = 0; i < done.length; i += 500) {
-- const part = done.slice(i, i + 500);
-- db.run(`DELETE FROM chat_staged_outbox WHERE seq IN (${part.map(() => '?').join(',')})`, part);
-- }
-- sent += changes.length;
-- relay.sent += changes.length;
-- relay.lastSentAt = new Date().toISOString();
-- if (refused.size) {
-- relay.lastError = `OpenVibe.Chat writes ${[...refused].join(', ')} but Live does too: run the handoff again (POST /internal/chat-tables/:table)`;
-- console.warn(`[ChatTables] ${relay.lastError}`);
-- break;
-- }
-- relay.lastError = null;
-- const other = (out.skipped || []).filter((s) => !refused.has(s.table));
-- if (other.length) console.warn(`[ChatTables] Chat skipped ${other.length} change(s):`, JSON.stringify(other.slice(0, 3)));
-- }
-- return { sent, pending: pending(table), error: relay.lastError };
-- })().finally(() => { busy = null; });
-- return busy;
--}
--
--/** Before a handoff to Chat: every captured change of the table acknowledged, or throw. */
--async function drain(table, { timeoutMs = 20000 } = {}) {
-- const until = Date.now() + timeoutMs;
-- while (pending(table) > 0) {
-- const r = await flush(table);
-- if (!pending(table)) break;
-- if (r.error || Date.now() > until) {
-- throw Object.assign(new Error(`${pending(table)} change(s) to ${table} not in OpenVibe.Chat yet${r.error ? `: ${r.error}` : ''}`), { status: 503 });
-- }
-- }
--}
--
--function relayStats() { return { ...relay, paused: paused(), pending: pending() }; }
--
--// ── Dual read ───────────────────────────────────────────────────
--
--const num = (v) => (typeof v === 'string' && /^-?\d+$/.test(v) ? Number(v) : v);
--// Live's readers of the staged tables and the slices each read touches.
--const READERS = [
-- ['getChannelModerationSettings', 'channel_moderation_settings', (a) => [{ channel_id: num(a[0]) }]],
-- ['getChannelModerators', 'channel_moderators', (a) => [{ channel_id: num(a[0]) }]],
-- ['isChannelModerator', 'channel_moderators', (a) => [{ channel_id: num(a[1]) }]],
-- ['getChannelsByModerator', 'channel_moderators', (a) => [{ user_id: num(a[0]) }]],
-- ['getEmoteById', 'emotes', (a) => [{ id: num(a[0]) }]],
-- ['getEmotesByUser', 'emotes', (a) => [{ user_id: num(a[0]) }]],
-- ['getChannelEmotes', 'emotes', (a) => [{ channel_owner_id: num(a[0]) }, { user_id: num(a[0]), channel_owner_id: null }]],
-- ['getGlobalEmotes', 'emotes', () => [{ is_global: 1 }]],
-- ['getChatAiSummary', 'chat_ai_summaries', (a) => [{ scope: a[0], subject_id: num(a[1]) || 0, window: a[2] }]],
-- ['getChatAiSummaries', 'chat_ai_summaries', (a) => [{ scope: a[0], subject_id: num(a[1]) || 0 }]],
-- ['getChatTimelineEvents', 'chat_timeline_events', (a) => [{ scope: (a[0] && a[0].scope) || 'global', subject_id: num(a[0] && a[0].subjectId) || 0 }]],
--];
--
--/** Chat's sliceHash (OpenVibe.Chat server/db/database.js): sha256 of the rows as arrays in column order. */
--function sliceHash(columns, rows) {
-- const body = JSON.stringify(rows.map((r) => columns.map((c) => (r[c] === undefined ? null : r[c]))));
-- return crypto.createHash('sha256').update(body).digest('hex');
--}
--const _cols = new Map();
--function liveColumns(table) {
-- if (!_cols.has(table)) _cols.set(table, db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name).sort());
-- return _cols.get(table);
--}
--function localSlice(table, where, columns) {
-- const keys = Object.keys(where);
-- const rows = db.all(`SELECT ${columns.join(', ')} FROM ${table} WHERE ${keys.map((k) => `${k} IS ?`).join(' AND ') || '1'} ORDER BY ${chatTables.TABLES[table].join(', ')}`, keys.map((k) => where[k]));
-- return { count: rows.length, hash: sliceHash(columns, rows), rows };
--}
--
--function bump(table, field, mismatch = null) {
-- try {
-- db.run(`INSERT INTO chat_dual_read_stats (tbl, compared, ${field}) VALUES (?, 1, 1)
-- ON CONFLICT(tbl) DO UPDATE SET compared = compared + 1, ${field} = ${field} + 1`, [table]);
-- if (mismatch) db.run('UPDATE chat_dual_read_stats SET last_mismatch_at = CURRENT_TIMESTAMP, last_mismatch = ? WHERE tbl = ?', [JSON.stringify(mismatch).slice(0, 2000), table]);
-- } catch { /* counting must never break a read */ }
--}
--
--function dualReadStats(table) {
-- try {
-- const r = db.get('SELECT compared, matched, mismatched, inconclusive, errors, last_mismatch_at, last_mismatch, since FROM chat_dual_read_stats WHERE tbl = ?', [table]);
-- if (!r) return { compared: 0, matched: 0, mismatched: 0, inconclusive: 0, errors: 0, last_mismatch_at: null, last_mismatch: null, since: null };
-- let last = null; try { last = r.last_mismatch ? JSON.parse(r.last_mismatch) : null; } catch { last = r.last_mismatch; }
-- return { ...r, last_mismatch: last };
-- } catch { return null; }
--}
--function resetDualReadStats(table) { try { db.run('DELETE FROM chat_dual_read_stats WHERE tbl = ?', [table]); } catch { /* */ } }
--
--const lastSlice = new Map(); // `${table}|${where}` → when last compared
--let budget = { minute: 0, used: 0 };
--
--/** Compare one slice with Chat's copy (in the background). → 'matched' | 'mismatched' | 'inconclusive' | 'error' */
--async function compare(table, where) {
-- const columns = liveColumns(table);
-- if (pending(table) > 0) { bump(table, 'inconclusive'); return 'inconclusive'; }
-- const before = localSlice(table, where, columns);
-- let theirs;
-- try {
-- theirs = await chatTables.callChat('stagedSlice', [table, where, columns], { timeoutMs: 5000 });
-- } catch (err) {
-- bump(table, 'errors');
-- return 'error';
-- }
-- const cols = Array.isArray(theirs.columns) ? theirs.columns : columns;
-- const mine = localSlice(table, where, cols);
-- // Live's rows changed meanwhile, or a change is on its way: no verdict.
-- if (mine.count !== before.count || localSlice(table, where, columns).hash !== before.hash || pending(table) > 0) { bump(table, 'inconclusive'); return 'inconclusive'; }
-- if (mine.count === theirs.count && mine.hash === theirs.hash) { bump(table, 'matched'); return 'matched'; }
-- const key = (r) => JSON.stringify(chatTables.TABLES[table].map((k) => r[k]));
-- const sample = { where, live: { count: mine.count, hash: mine.hash.slice(0, 16) }, chat: { count: theirs.count, hash: String(theirs.hash).slice(0, 16) } };
-- if (Array.isArray(theirs.rows) && mine.rows.length <= 50) {
-- const a = new Map(mine.rows.map((r) => [key(r), JSON.stringify(cols.map((c) => r[c] ?? null))]));
-- const b = new Map(theirs.rows.map((r) => [key(r), JSON.stringify(cols.map((c) => r[c] ?? null))]));
-- sample.only_live = [...a.keys()].filter((k) => !b.has(k)).slice(0, 5);
-- sample.only_chat = [...b.keys()].filter((k) => !a.has(k)).slice(0, 5);
-- sample.differ = [...a.keys()].filter((k) => b.has(k) && a.get(k) !== b.get(k)).slice(0, 5);
-- }
-- bump(table, 'mismatched', sample);
-- console.warn(`[ChatTables] dual read: ${table} ${JSON.stringify(where)} differs — Live ${mine.count} row(s), Chat ${theirs.count}`);
-- return 'mismatched';
--}
--
--/** A read of `table` touched these slices: compare them later, within the rate limits. */
--function observe(table, slices) {
-- if (chatTables.authority(table) !== 'live' || !chatTables.dualRead(table)) return;
-- const now = Date.now();
-- const minute = Math.floor(now / 60_000);
-- if (budget.minute !== minute) budget = { minute, used: 0 };
-- for (const where of slices) {
-- const k = `${table}|${JSON.stringify(where)}`;
-- if (now - (lastSlice.get(k) || 0) < SLICE_EVERY_MS || budget.used >= COMPARES_PER_MIN) continue;
-- lastSlice.set(k, now);
-- if (lastSlice.size > 5000) lastSlice.delete(lastSlice.keys().next().value);
-- budget.used++;
-- setImmediate(() => { compare(table, where).catch(() => {}); });
-- }
--}
--
--const originals = {};
--function installDualRead() {
-- for (const [fn, table, slices] of READERS) {
-- if (typeof db[fn] !== 'function' || originals[fn]) continue;
-- const orig = originals[fn] = db[fn];
-- db[fn] = function dualReadObserved(...args) {
-- const out = orig.apply(this, args);
-- try { observe(table, slices(args)); } catch { /* never breaks the read */ }
-- return out;
-- };
-- }
-- try {
-- const tags = require('./tags');
-- if (!originals.getUserTags) {
-- const orig = originals.getUserTags = tags.getUserTags;
-- tags.getUserTags = function dualReadObserved(userId) {
-- const out = orig(userId);
-- try { observe('user_tags', [{ user_id: num(userId) }]); } catch { /* */ }
-- return out;
-- };
-- }
-- } catch { /* no tags module */ }
--}
--
--// ── Start ───────────────────────────────────────────────────────
--
--function start() {
-- if (started) return;
-- started = true;
-- ensureTables();
-- installCapture();
-- installDualRead();
-- require('../utils/jobs').every('chat-staged-relay', RELAY_MS, () => flush(), { initialDelayMs: RELAY_MS });
-- const queued = pending();
-- console.log(`[ChatTables] staged tables: ${Object.keys(chatTables.TABLES).map((t) => `${t}=${chatTables.authority(t)}${chatTables.dualRead(t) ? '+dual-read' : ''}`).join(', ')}${queued ? ` (${queued} change(s) queued for Chat)` : ''}`);
--}
--
--module.exports = {
-- start, ensureTables, installCapture, setCapture, pending, flush, drain, buildChanges, relayStats, paused, setPaused,
-- compare, observe, dualReadStats, resetDualReadStats, sliceHash, READERS,
-- _restore() { for (const [fn, orig] of Object.entries(originals)) { if (fn === 'getUserTags') require('./tags').getUserTags = orig; else db[fn] = orig; delete originals[fn]; } started = false; },
--};
ddiff --git a/server/chat/chat-tables.js b/server/chat/chat-tables.js
ddeleted file mode 100644
iindex f73c8fea..00000000
---- a/server/chat/chat-tables.js
++++ /dev/null
@@@ -1,241 +0,0 @@
--'use strict';
--/**
-- * The staged chat tables (roadmap C-04; OpenVibe.Chat docs/staged-tables-cutover.md): who writes
-- * channel_moderators, channel_moderation_settings, emotes, user_tags, chat_ai_summaries and
-- * chat_timeline_events, one table at a time.
-- *
-- * authority(table) is 'live' or 'chat', from app_state `chat_table_authority` (JSON; a table not in
-- * it is 'live', so deploying this changes nothing). It is always 'live' unless CHAT_AUTHORITY=chat:
-- * without it Live runs chat itself and OpenVibe.Chat is out of the picture.
-- *
-- * 'live' Live writes its table, as before (write() runs Live's own function). Every change is
-- * captured and relayed to Chat's copy (chat-tables-sync.js). With the table's dual-read
-- * flag (app_state `chat_table_dual_read`), Live's reads of it are also compared with Chat's
-- * copy and mismatches are counted; Live's answers never change.
-- * 'chat' Chat writes it. write() hands the writer's call to Chat over the bridge (POST
-- * /internal/live/calls, chat.live_bridge.write, awaited, with an idempotency key) and
-- * applies the rows Chat answers to Live's table, which is from then on the read mirror
-- * Chat keeps current (POST /internal/chat-effects/mirror). Live's readers do not change.
-- *
-- * Moving a table is setAuthority() (POST /internal/chat-tables/:table with X-Internal-Key), never
-- * automatic. The table's writers wait while it runs. To 'chat': Live's queued changes reach Chat,
-- * Chat takes the table, Live stops writing it. Back to 'live': Chat sends Live every change it made
-- * to the table and gives it back, then Live writes it again. Running it again is safe and brings Live
-- * and Chat back into agreement after a failed attempt.
-- *
-- * Nothing here starts at require time; init() (server start, CHAT_AUTHORITY=chat only) installs the
-- * capture, the dual read and the relay. A restore drill never calls it.
-- */
--const crypto = require('crypto');
--const db = require('../db/database');
--const { isRemote, CHAT_URL } = require('./chat-authority');
--
--// The staged tables and their keys (Chat's STAGED_KEYS).
--const TABLES = {
-- channel_moderators: ['id'],
-- channel_moderation_settings: ['channel_id'],
-- emotes: ['id'],
-- user_tags: ['id'],
-- chat_ai_summaries: ['id'],
-- chat_timeline_events: ['id'],
--};
--// Live's writes to them: database.js function → table. Chat's database.js has the same functions.
--// user_tags has no writer in Live (chat tags are read-only since the game moved to OpenVibe.Games).
--const OPS = {
-- addChannelModerator: 'channel_moderators',
-- removeChannelModerator: 'channel_moderators',
-- upsertChannelModerationSettings: 'channel_moderation_settings',
-- setChannelAlertSound: 'channel_moderation_settings',
-- createEmote: 'emotes',
-- updateEmote: 'emotes',
-- deleteEmote: 'emotes',
-- setEmoteMedia: 'emotes',
-- upsertChatAiSummary: 'chat_ai_summaries',
-- addChatTimelineEvents: 'chat_timeline_events',
--};
--
--const AUTHORITY_KEY = 'chat_table_authority';
--const DUAL_READ_KEY = 'chat_table_dual_read';
--const AUDIENCE = 'openvibe.chat';
--const BOOT = `tables-${crypto.randomUUID()}`;
--
--function httpError(status, message) { return Object.assign(new Error(message), { status }); }
--
--// ── Authority and dual-read flags ───────────────────────────────
--
--function readJson(key) {
-- try {
-- const v = db.getState(key);
-- const o = v ? JSON.parse(v) : {};
-- return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
-- } catch { return {}; }
--}
--let cache = { at: 0, authority: {}, dual: {} };
--function flags() {
-- if (Date.now() - cache.at > 1000) cache = { at: Date.now(), authority: readJson(AUTHORITY_KEY), dual: readJson(DUAL_READ_KEY) };
-- return cache;
--}
--/** What app_state says, whether or not CHAT_AUTHORITY=chat. */
--function stored(table) { return flags().authority[table] === 'chat' ? 'chat' : 'live'; }
--/** Who writes the table now. */
--function authority(table) { return isRemote() && stored(table) === 'chat' ? 'chat' : 'live'; }
--function dualRead(table) { return isRemote() && flags().dual[table] === true; }
--
--function saveFlag(key, table, value) {
-- const cur = readJson(key);
-- if (value === undefined || value === null || value === false || value === 'live') delete cur[table];
-- else cur[table] = value;
-- db.setState(key, JSON.stringify(cur));
-- cache.at = 0;
--}
--function setDualRead(table, on) {
-- if (!TABLES[table]) throw httpError(404, `${table} is not a staged table`);
-- saveFlag(DUAL_READ_KEY, table, on ? true : undefined);
-- return dualRead(table);
--}
--
--// ── Chat's bridge ───────────────────────────────────────────────
--
--/** One op on Chat's bridge → its result; throws with Chat's refusal. A lost answer is retried once with the same key. */
--async function callChat(op, args, { key = null, timeoutMs = 10000 } = {}) {
-- const principal = require('../net/network-principal');
-- const body = JSON.stringify({ boot: BOOT, ops: [{ seq: 1, op, args, ...(key ? { key } : {}) }] });
-- let lastErr = null;
-- for (let attempt = 0; attempt < 2; attempt++) {
-- let res;
-- try {
-- res = await fetch(`${CHAT_URL}/internal/live/calls`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) },
-- body,
-- signal: AbortSignal.timeout(timeoutMs),
-- });
-- } catch (err) {
-- lastErr = httpError(503, `OpenVibe.Chat unreachable: ${err.message}`);
-- if (!key) break; // only an idempotent op is sent twice
-- continue;
-- }
-- if (res.status === 401 && attempt === 0) { principal.invalidate(AUDIENCE); continue; }
-- if (!res.ok) throw httpError(502, `OpenVibe.Chat answered ${res.status}`);
-- const out = await res.json();
-- const r = out && Array.isArray(out.results) ? out.results[0] : null;
-- if (!r) throw httpError(502, 'OpenVibe.Chat gave no result');
-- if (!r.ok) throw httpError(409, `OpenVibe.Chat refused ${op === 'db' ? args[0] : op}: ${r.error}`);
-- return r.result;
-- }
-- throw lastErr;
--}
--
--// ── Writes ──────────────────────────────────────────────────────
--
--const handoffs = new Map(); // table → promise of the running handoff
--
--/**
-- * Live's write to a staged table, wherever the table is written: `op` is the database.js function
-- * (OPS), `args` its arguments, the answer what that function returns. Await it.
-- */
--async function write(op, ...args) {
-- const table = OPS[op];
-- if (!table) throw new Error(`${op} is not a staged-table write`);
-- const running = handoffs.get(table);
-- if (running) await running.catch(() => {});
-- if (authority(table) === 'live') return db[op](...args);
-- const r = await callChat('db', [op, ...args], { key: `staged:${crypto.randomUUID()}` });
-- // Live's copy follows at once (Chat's mirror sends the same rows again later).
-- if (r && Array.isArray(r.mirror) && r.mirror.length) {
-- const out = require('./live-context-routes').applyMirror(r.mirror);
-- if (out.skipped.length) console.warn(`[ChatTables] ${op}: ${out.skipped.length} row(s) of Chat's answer not applied here:`, JSON.stringify(out.skipped.slice(0, 2)));
-- }
-- return r ? r.value : null;
--}
--
--/** Whether Live takes Chat's mirror rows for this table (Chat writes it, or it is being handed back). */
--function acceptsMirror(table) {
-- return !!TABLES[table] && isRemote() && (stored(table) === 'chat' || handoffs.has(table));
--}
--
--// ── The handoff ─────────────────────────────────────────────────
--
--/**
-- * Hand `table` to `target`. `force` (back to 'live' only): Live takes the table without asking Chat —
-- * for when Chat is down; first send Chat's queued changes (OpenVibe.Chat scripts/mirror-flush.js) and
-- * set Chat's side (scripts/table-authority.js set <table> live --force), docs/staged-tables-cutover.md.
-- */
--async function setAuthority(table, target, { by = 'operator', force = false } = {}) {
-- if (!TABLES[table]) throw httpError(404, `${table} is not a staged table`);
-- if (target !== 'live' && target !== 'chat') throw httpError(400, 'authority is "live" or "chat"');
-- if (force && target !== 'live') throw httpError(400, 'force is only for taking a table back');
-- if (!isRemote()) throw httpError(409, 'CHAT_AUTHORITY is not "chat": Live runs chat and writes every staged table itself');
-- if (handoffs.has(table)) throw httpError(409, `a handoff of ${table} is already running`);
-- const sync = require('./chat-tables-sync');
-- let finish;
-- handoffs.set(table, new Promise((resolve) => { finish = resolve; }));
-- const before = stored(table);
-- const started = Date.now();
-- try {
-- if (target === 'chat') {
-- await sync.drain(table); // 1. Live's changes are in Chat's copy
-- const r = await callChat('setTableAuthority', [table, 'chat'], { timeoutMs: 20000 }); // 2. Chat writes it
-- if (!r || r.authority !== 'chat') throw httpError(502, `OpenVibe.Chat did not take ${table}`);
-- saveFlag(AUTHORITY_KEY, table, 'chat'); // 3. Live stops writing it
-- sync.setCapture(table, false);
-- } else {
-- if (force) {
-- console.warn(`[ChatTables] ${table}: taken back without asking OpenVibe.Chat (force, ${by})`);
-- } else {
-- const r = await callChat('setTableAuthority', [table, 'live'], { timeoutMs: 30000 }); // 1–2. Chat's changes are here; Chat stops
-- if (!r || r.authority !== 'live') throw httpError(502, `OpenVibe.Chat did not give ${table} back`);
-- }
-- sync.setCapture(table, true); // 3. Live writes it again
-- saveFlag(AUTHORITY_KEY, table, undefined);
-- }
-- console.log(`[ChatTables] ${table}: ${before} → ${target} (${by}${force ? ', forced' : ''}, ${Date.now() - started} ms)`);
-- return { table, before, authority: target };
-- } catch (err) {
-- console.warn(`[ChatTables] ${table}: handoff to ${target} failed, still ${stored(table)}: ${err.message}`);
-- throw err;
-- } finally {
-- handoffs.delete(table);
-- finish();
-- }
--}
--
--// ── Status ──────────────────────────────────────────────────────
--
--/** Every staged table: who writes it (here and, when asked and reachable, in Chat), flags, the relay, the dual-read counts. */
--async function status({ askChat = true } = {}) {
-- const sync = require('./chat-tables-sync');
-- let chat = null;
-- // (a restore drill asks no other service anything)
-- if (askChat && isRemote() && !require('../drill').enabled) {
-- try { chat = await callChat('tableAuthority', [], { timeoutMs: 3000 }); } catch (err) { chat = { error: err.message }; }
-- }
-- const tables = {};
-- for (const t of Object.keys(TABLES)) {
-- tables[t] = {
-- authority: authority(t),
-- stored: stored(t),
-- chat_authority: chat && !chat.error ? chat[t] || null : null,
-- dual_read: dualRead(t),
-- handoff_running: handoffs.has(t),
-- outbox_pending: sync.pending(t),
-- dual_read_stats: sync.dualReadStats(t),
-- };
-- if (tables[t].chat_authority && tables[t].chat_authority !== tables[t].authority) tables[t].disagree = true;
-- }
-- return { chat_authority: isRemote() ? 'chat' : 'live', chat_error: chat && chat.error ? chat.error : null, relay: sync.relayStats(), tables };
--}
--
--/** Server start (CHAT_AUTHORITY=chat only): capture, dual read, relay. */
--function init() {
-- if (!isRemote()) return false;
-- require('./chat-tables-sync').start();
-- return true;
--}
--
--module.exports = {
-- TABLES, OPS, AUTHORITY_KEY, DUAL_READ_KEY,
-- authority, stored, dualRead, setDualRead, acceptsMirror,
-- write, setAuthority, status, init, callChat,
-- _resetCache() { cache.at = 0; },
--};
ddiff --git a/server/chat/insight-client.js b/server/chat/insight-client.js
nnew file mode 100644
iindex 00000000..b5daa048
---- /dev/null
++++ b/server/chat/insight-client.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * The chat-AI insight OpenVibe.Chat keeps (roadmap T3): the global chat overview and each chatter's
++ * summary live in Chat's chat_ai_summaries now, so Live's features that fold them into their own
++ * work read them from Chat's public read API (/api/chat/ai/…, the same answers the browser gets):
++ *
++ * getGlobal() → { overview, memory, timeline, … } | null
++ * getUser(userId) → a signed-in chatter's insight | null
++ * getAnon(anonId) → an anonymous chatter's insight ("anon123") | null
++ * getRelay(platform, username) → a relayed chatter's insight | null
++ *
++ * Each has a synchronous peek*() twin for the few callers that cannot await (the persona's prompt
++ * context): it answers what is cached, or null, and refreshes in the background, so the next call
++ * has it. Answers are cached 60 s; a failure answers null, logs once and is cached too, so a Chat
++ * outage costs the insight text and never a request. Off under LIVE_DRILL (a restore drill calls no
++ * other service).
++ */
++const { CHAT_URL } = require('./chat-authority');
++const CACHE_TTL_MS = 60_000;
++const TIMEOUT_MS = 4000;
++
++const cache = new Map(); // key → { at, value }
++const inflight = new Map(); // key → Promise
++let lastLog = null;
++
++function drill() { try { return require('../drill').enabled; } catch { return false; } }
++
++function cached(key) { const h = cache.get(key); return h && Date.now() - h.at < CACHE_TTL_MS ? h : null; }
++function remember(key, value) {
++ cache.set(key, { at: Date.now(), value });
++ if (cache.size > 5000) cache.delete(cache.keys().next().value);
++}
++
++function read(key, path) {
++ const hit = cached(key);
++ if (hit) return Promise.resolve(hit.value);
++ if (inflight.has(key)) return inflight.get(key);
++ const p = (async () => {
++ if (drill()) return null;
++ try {
++ const res = await fetch(`${CHAT_URL}/api/chat/ai${path}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (!res.ok) throw new Error(`answered ${res.status}`);
++ const out = await res.json();
++ const value = (out && out.insight) || null;
++ lastLog = null;
++ remember(key, value);
++ return value;
++ } catch (err) {
++ const m = `OpenVibe.Chat insight ${path}: ${(err && err.message) || err}`;
++ if (m !== lastLog) { lastLog = m; console.warn(`[ChatInsight] ${m}`); }
++ remember(key, null);
++ return null;
++ }
++ })().finally(() => inflight.delete(key));
++ inflight.set(key, p);
++ return p;
++}
++
++function peek(key, path) {
++ const hit = cached(key);
++ if (hit) return hit.value;
++ read(key, path).catch(() => { /* logged in read() */ }); // floating-ok: warms the cache for the next peek
++ return null;
++}
++
++const userPath = (id) => `/user/${Number(id)}`;
++const anonPath = (id) => `/anon/${encodeURIComponent(String(id))}`;
++const relayPath = (platform, username) => `/relay/${encodeURIComponent(String(platform))}/${encodeURIComponent(String(username))}`;
++const validUser = (id) => Number.isInteger(Number(id)) && Number(id) > 0;
++const validAnon = (id) => /^anon\d+$/i.test(String(id || ''));
++
++module.exports = {
++ getGlobal: () => read('g', '/global'),
++ getUser: (id) => (validUser(id) ? read(`u:${Number(id)}`, userPath(id)) : Promise.resolve(null)),
++ getAnon: (id) => (validAnon(id) ? read(`a:${id}`, anonPath(id)) : Promise.resolve(null)),
++ getRelay: (platform, username) => (platform && username ? read(`r:${platform}:${username}`, relayPath(platform, username)) : Promise.resolve(null)),
++ peekGlobal: () => peek('g', '/global'),
++ peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
++ peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(id)) : null),
++ peekRelay: (platform, username) => (platform && username ? peek(`r:${platform}:${username}`, relayPath(platform, username)) : null),
++ _reset() { cache.clear(); inflight.clear(); lastLog = null; },
++};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex ae6fc8ac..eeb2e553 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -3113,21 +3113,6 @@ function setClipTranscript(clipId, transcript, segments) {
_ensureClipAiState(clipId);
return run('UPDATE clip_ai_state SET ai_transcript_json = ? WHERE clip_id = ?', [_segJson(segments) ?? (transcript ? JSON.stringify([]) : null), clipId]);
}
--// ── Streamer alert sounds (donation / goal-reached) ──────────
--// Stored on channel_moderation_settings; url is the on-disk path (read server-side and
--// broadcast as base64 to viewers, so it isn't publicly served).
--function setChannelAlertSound(channelId, kind, url, mime) {
-- if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {
-- run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);
-- }
-- const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';
-- return run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);
--}
--function getChannelAlertSoundsByUser(userId) {
-- const ch = getChannelByUserId(userId);
-- if (!ch) return {};
-- return get('SELECT donation_sound_url, donation_sound_mime, goal_sound_url, goal_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [ch.id]) || {};
--}
function getStreamMemoriesInRange(streamId, startSec, endSec) {
return all('SELECT * FROM stream_memories WHERE stream_id = ? AND offset_seconds BETWEEN ? AND ? ORDER BY offset_seconds ASC', [streamId, startSec, endSec]);
}
@@@ -4815,59 +4800,6 @@ function getChatSamplesInChannel(channelUserId, { userId = null, relay = null, l
[...params, Math.max(1, Math.min(200, limit))]);
}
--// ── Chat AI summaries (global overview/timeline + per-user insights) ──────────
--// Messages worth analyzing: real chat, exclude system noise + deleted + expired.
--const _CHAT_AI_WHERE = `cm.is_deleted = 0 AND cm.message_type != 'system'
-- AND COALESCE(cm.source_platform,'') != 'ai'
-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
--
--function getMaxChatMessageId() {
-- return get('SELECT MAX(id) AS m FROM chat_messages')?.m || 0;
--}
--
--// Count analyzable messages newer than a high-water id (optionally for one user).
--function countChatMessagesSince(afterId, userId = null) {
-- let sql = `SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${_CHAT_AI_WHERE} AND cm.id > ?`;
-- const params = [afterId || 0];
-- if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
-- return get(sql, params)?.c || 0;
--}
--
--// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
--// order 'asc' for chronological batches; 'desc'+limit for "most recent N".
--function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
-- let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,
-- cm.stream_id, cm.channel_user_id, cm.is_global,
-- ch.username AS channel_username, ch.display_name AS channel_display
-- FROM chat_messages cm
-- LEFT JOIN users ch ON cm.channel_user_id = ch.id
-- WHERE ${_CHAT_AI_WHERE}`;
-- const params = [];
-- if (afterId != null) { sql += ' AND cm.id > ?'; params.push(afterId); }
-- if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
-- if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
-- sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
-- params.push(Math.max(1, Math.min(2000, limit)));
-- const rows = all(sql, params);
-- return order === 'desc' ? rows.reverse() : rows;
--}
--
--// Timestamp of the Nth-most-recent analyzable message — drives the adaptive
--// overview window (busy chat → short window, quiet → wide). Optionally per-user.
--function getNthRecentChatTs(n, userId = null) {
-- let sql = `SELECT cm.timestamp AS ts FROM chat_messages cm WHERE ${_CHAT_AI_WHERE}`;
-- const params = [];
-- if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
-- sql += ' ORDER BY cm.id DESC LIMIT 1 OFFSET ?';
-- params.push(Math.max(0, (n | 0) - 1));
-- return get(sql, params)?.ts || null;
--}
--
--function getChatAiSummary(scope, subjectId, window) {
-- return get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?',
-- [scope, subjectId || 0, window]) || null;
--}
--// Append AI timeline "notable moments" to the growing log (deduped by scope+ts+label).
// ── Per-user TTS voice overrides (admin-set) ─────────────────
function getTtsVoiceOverride(identityKey) {
try {
@@@ -4907,84 +4839,6 @@ function countEasterEggSolves(eggDate) {
try { return get('SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?', [eggDate])?.n || 0; } catch { return 0; }
}
--function addChatTimelineEvents(scope, subjectId, events) {
-- if (!Array.isArray(events) || !events.length) return 0;
-- let n = 0;
-- for (const e of events) {
-- if (!e || !e.label || !e.ts) continue;
-- try {
-- run('INSERT OR IGNORE INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?)',
-- [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);
-- n++;
-- } catch { /* */ }
-- }
-- return n;
--}
--// Paginated + searchable timeline browse. `before` = epoch ms (exclusive upper bound); `q`
--// filters label/detail; `since` = epoch ms lower bound (for period jumps). Newest first.
--function getChatTimelineEvents({ scope = 'global', subjectId = 0, before = null, since = null, q = null, limit = 25 } = {}) {
-- const conds = ['scope = ?', 'subject_id = ?'];
-- const params = [scope, subjectId || 0];
-- if (before) { conds.push("ts < datetime(?, 'unixepoch')"); params.push(Math.floor(before / 1000)); }
-- if (since) { conds.push("ts >= datetime(?, 'unixepoch')"); params.push(Math.floor(since / 1000)); }
-- if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label LIKE ? OR detail LIKE ?)'); params.push(like, like); }
-- params.push(Math.min(60, Math.max(1, limit)));
-- try {
-- return all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
-- } catch { return []; }
--}
--function getChatAiSummaries(scope, subjectId) {
-- return all('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? ORDER BY window',
-- [scope, subjectId || 0]);
--}
--function upsertChatAiSummary(sfx) {
-- const {
-- scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
-- message_count = 0, window_message_count = 0, last_message_id = 0,
-- window_label = '', window_start = null, window_end = null,
-- } = sfx;
-- return run(
-- `INSERT INTO chat_ai_summaries
-- (scope, subject_id, window, overview, memory_json, timeline_json, message_count,
-- window_message_count, last_message_id, window_label, window_start, window_end, updated_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
-- ON CONFLICT(scope, subject_id, window) DO UPDATE SET
-- overview = excluded.overview,
-- memory_json = excluded.memory_json,
-- timeline_json = excluded.timeline_json,
-- message_count = excluded.message_count,
-- window_message_count = excluded.window_message_count,
-- last_message_id = excluded.last_message_id,
-- window_label = excluded.window_label,
-- window_start = excluded.window_start,
-- window_end = excluded.window_end,
-- updated_at = CURRENT_TIMESTAMP`,
-- [scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
-- window_message_count, last_message_id, window_label, window_start, window_end]
-- );
--}
--
--// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.
--// Bounded to recent messages so the GROUP BY stays cheap; caps rows returned.
--function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
-- const sql = `
-- SELECT cm.user_id AS uid,
-- MAX(cm.id) AS max_id,
-- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs,
-- COALESCE(cs.last_message_id, 0) AS hw,
-- cs.updated_at AS last_update
-- FROM chat_messages cm
-- LEFT JOIN chat_ai_summaries cs
-- ON cs.scope = 'user' AND cs.subject_id = cm.user_id AND cs.window = 'rolling'
-- WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NOT NULL AND cm.timestamp >= ?
-- GROUP BY cm.user_id
-- HAVING new_msgs > 0
-- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
-- ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
-- LIMIT ?`;
-- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
--}
--
// 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) {
@@@ -5036,40 +4890,9 @@ function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0
return { messages: rows, total };
}
--// Relay messages for AI batching (mirrors getChatMessagesForAi).
--function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limit = 300, order = 'asc' } = {}) {
-- let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.source_platform
-- FROM chat_messages cm
-- WHERE ${_CHAT_AI_WHERE} AND ${_RELAY_MATCH}`;
-- const params = _relayMatchParams(platform, rawUsername);
-- if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
-- sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
-- params.push(Math.max(1, Math.min(2000, limit)));
-- const rows = all(sql, params);
-- return order === 'desc' ? rows.reverse() : rows;
--}
--
--// Relay users with new activity since their last AI summary (or never summarised).
--// last_seen advances on every message (see recordRelayUser), so last_seen > summary
--// updated_at means "chatted since we last analysed them".
--function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } = {}) {
-- return all(`
-- SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen,
-- cs.updated_at AS last_update
-- FROM relay_users r
-- LEFT JOIN chat_ai_summaries cs
-- ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid
-- WHERE r.last_seen >= ?
-- AND r.message_count >= ?
-- AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen)
-- ORDER BY (cs.updated_at IS NULL) DESC, r.last_seen DESC
-- LIMIT ?`, [lookbackIso, threshold, Math.max(1, limit)]);
--}
--
--// ── Anonymous chatters: chat logs + AI insight (mirror the relay path) ──────────
++// ── 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). Their chat-AI insight is keyed in chat_ai_summaries by scope='anon',
--// subject_id = the numeric N.
++// their username).
function anonSubjectId(anonId) {
const m = /^anon(\d+)$/i.exec(String(anonId || ''));
return m ? parseInt(m[1], 10) : 0;
@@@ -5115,39 +4938,6 @@ function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {})
return { messages: rows, total };
}
--// Anon messages for AI batching (mirrors getChatMessagesForAi / getRelayChatMessagesForAi).
--function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order = 'asc' } = {}) {
-- let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp
-- FROM chat_messages cm
-- WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id = ?`;
-- const params = [String(anonId)];
-- if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
-- sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
-- params.push(Math.max(1, Math.min(2000, limit)));
-- const rows = all(sql, params);
-- return order === 'desc' ? rows.reverse() : rows;
--}
--
--// Anons with enough new chat activity (or a stale summary) to warrant an AI refresh.
--function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit = 2 } = {}) {
-- const sql = `
-- SELECT cm.anon_id AS anon_id,
-- MAX(cm.id) AS max_id,
-- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs
-- FROM chat_messages cm
-- LEFT JOIN chat_ai_summaries cs
-- ON cs.scope = 'anon' AND cs.window = 'rolling'
-- AND cs.subject_id = CAST(SUBSTR(cm.anon_id, 5) AS INTEGER)
-- WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id IS NOT NULL
-- AND cm.anon_id LIKE 'anon%' AND cm.timestamp >= ?
-- GROUP BY cm.anon_id
-- HAVING new_msgs > 0
-- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
-- ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
-- LIMIT ?`;
-- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
--}
--
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
@@@ -5743,94 +5533,6 @@ function isUsernameReserved(username) {
return !!vk;
}
--// ── Emote helpers ────────────────────────────────────────────
--
--function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100 }) {
-- return run(
-- `INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size) || 100))]
-- );
--}
--
--function getEmoteById(id) {
-- return get('SELECT e.*, u.username FROM emotes e JOIN users u ON e.user_id = u.id WHERE e.id = ?', [id]);
--}
--
--function getEmotesByUser(userId) {
-- return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
--}
--
--function getGlobalEmotes() {
-- return all('SELECT e.*, u.username FROM emotes e JOIN users u ON e.user_id = u.id WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code');
--}
--
--function getChannelEmotes(userId) {
-- // A channel's emotes are those explicitly targeted at this owner (viewer uploads),
-- // plus legacy emotes the owner uploaded to their own channel (channel_owner_id NULL).
-- return all(
-- `SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name
-- FROM emotes e
-- JOIN users u ON e.user_id = u.id
-- LEFT JOIN users up ON e.user_id = up.id
-- WHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))
-- AND e.is_approved = 1
-- ORDER BY code`,
-- [userId, userId]
-- );
--}
--
--function countChannelEmotes(ownerId) {
-- const row = get(
-- 'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
-- [ownerId, ownerId]
-- );
-- return row ? row.count : 0;
--}
--
--function getChannelEmoteByCode(ownerId, code) {
-- return get(
-- `SELECT * FROM emotes
-- WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
-- LIMIT 1`,
-- [code, ownerId, ownerId]
-- );
--}
--
--function deleteEmote(id) {
-- return run('DELETE FROM emotes WHERE id = ?', [id]);
--}
--
--// Edit an emote in place (rename the code and/or change display size %)
--// so streamers don't have to delete + re-upload.
--function updateEmote(id, { code, size }) {
-- const sets = [];
-- const params = [];
-- if (code !== undefined) { sets.push('code = ?'); params.push(code); }
-- if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size) || 100))); }
-- if (!sets.length) return { changes: 0 };
-- params.push(id);
-- return run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, params);
--}
--
--// The emote's copy on OpenVibe.Media (media-proxy/asset-sync.js), through chat-tables.write().
--function setEmoteMedia(id, mediaUrl, mediaAssetId) {
-- return run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);
--}
--
--function getEmoteByCode(code, userId) {
-- // Check channel emotes first, then global
-- return get(
-- `SELECT * FROM emotes WHERE code = ? AND (user_id = ? OR is_global = 1) AND is_approved = 1 ORDER BY is_global ASC LIMIT 1`,
-- [code, userId]
-- );
--}
--
--function countUserEmotes(userId) {
-- const row = get('SELECT COUNT(*) as count FROM emotes WHERE user_id = ?', [userId]);
-- return row ? row.count : 0;
--}
--
// ── 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(
@@@ -6410,199 +6112,6 @@ function getChannelById(id) {
return get('SELECT * FROM channels WHERE id = ?', [id]);
}
--// ── Channel Moderators ───────────────────────────────────────
--
--function isChannelModerator(userId, channelId) {
-- const row = get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);
-- return !!row;
--}
--
--function addChannelModerator(channelId, userId, addedBy) {
-- return run(
-- 'INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)',
-- [channelId, userId, addedBy]
-- );
--}
--
--function removeChannelModerator(channelId, userId) {
-- return run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
--}
--
--function getChannelModerators(channelId) {
-- return all(`
-- SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,
-- u.username, u.display_name, u.avatar_url,
-- a.username as added_by_username
-- FROM channel_moderators cm
-- JOIN users u ON cm.user_id = u.id
-- LEFT JOIN users a ON cm.added_by = a.id
-- WHERE cm.channel_id = ?
-- ORDER BY cm.created_at ASC
-- `, [channelId]);
--}
--
--function getChannelsByModerator(userId) {
-- return all(`
-- SELECT cm.channel_id, c.title, c.user_id, u.username as owner_username
-- FROM channel_moderators cm
-- JOIN channels c ON cm.channel_id = c.id
-- JOIN users u ON c.user_id = u.id
-- WHERE cm.user_id = ?
-- `, [userId]);
--}
--
--// ── Channel Moderation Settings ──────────────────────────────
--
--function getChannelModerationSettings(channelId) {
-- return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])
-- || {
-- channel_id: channelId,
-- slow_mode_seconds: 0,
-- followers_only: 0,
-- emote_only: 0,
-- allow_anonymous: 1,
-- links_allowed: 1,
-- gifs_enabled: 1,
-- account_age_gate_hours: 0,
-- caps_percentage_limit: 0,
-- aggressive_filter: 0,
-- max_message_length: 500,
-- tts_max_length: 200,
-- slur_filter_enabled: 0,
-- slur_filter_use_builtin: 1,
-- slur_filter_terms: '',
-- slur_filter_regexes: '',
-- slur_filter_nudge_message: '',
-- slur_filter_disabled_categories: '[]',
-- ip_approval_mode: 0,
-- soundboard_enabled: 1,
-- soundboard_allow_pitch: 1,
-- soundboard_allow_speed: 1,
-- soundboard_banned_ids: '',
-- viewer_auto_delete_enabled: 1,
-- viewer_delete_all_enabled: 1,
-- custom_emotes_enabled: 1,
-- custom_sounds_enabled: 1,
-- max_sound_seconds: 10,
-- uploads_mods_only: 0,
-- mods_can_edit_about: 0,
-- emote_scale: 100,
-- emote_size_min: 50,
-- emote_size_max: 200,
-- sounds_mods_only: 0,
-- sound_min_speed: 0.5,
-- sound_max_speed: 3.0,
-- sound_min_pitch_cents: -1200,
-- sound_max_pitch_cents: 1200,
-- sub_only: 0,
-- };
--}
--
--function upsertChannelModerationSettings(channelId, fields) {
-- const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
-- if (existing) {
-- const updates = [];
-- const params = [];
-- if (fields.slow_mode_seconds !== undefined) { updates.push('slow_mode_seconds = ?'); params.push(fields.slow_mode_seconds); }
-- if (fields.followers_only !== undefined) { updates.push('followers_only = ?'); params.push(fields.followers_only ? 1 : 0); }
-- if (fields.emote_only !== undefined) { updates.push('emote_only = ?'); params.push(fields.emote_only ? 1 : 0); }
-- if (fields.allow_anonymous !== undefined) { updates.push('allow_anonymous = ?'); params.push(fields.allow_anonymous ? 1 : 0); }
-- if (fields.links_allowed !== undefined) { updates.push('links_allowed = ?'); params.push(fields.links_allowed ? 1 : 0); }
-- if (fields.gifs_enabled !== undefined) { updates.push('gifs_enabled = ?'); params.push(fields.gifs_enabled ? 1 : 0); }
-- if (fields.account_age_gate_hours !== undefined) { updates.push('account_age_gate_hours = ?'); params.push(Number(fields.account_age_gate_hours) || 0); }
-- if (fields.caps_percentage_limit !== undefined) { updates.push('caps_percentage_limit = ?'); params.push(Number(fields.caps_percentage_limit) || 0); }
-- if (fields.aggressive_filter !== undefined) { updates.push('aggressive_filter = ?'); params.push(fields.aggressive_filter ? 1 : 0); }
-- if (fields.max_message_length !== undefined) { updates.push('max_message_length = ?'); params.push(Math.max(50, Number(fields.max_message_length) || 500)); }
-- if (fields.slur_filter_enabled !== undefined) { updates.push('slur_filter_enabled = ?'); params.push(fields.slur_filter_enabled ? 1 : 0); }
-- if (fields.slur_filter_use_builtin !== undefined) { updates.push('slur_filter_use_builtin = ?'); params.push(fields.slur_filter_use_builtin ? 1 : 0); }
-- if (fields.slur_filter_terms !== undefined) { updates.push('slur_filter_terms = ?'); params.push(String(fields.slur_filter_terms || '').slice(0, 4000)); }
-- if (fields.slur_filter_regexes !== undefined) { updates.push('slur_filter_regexes = ?'); params.push(String(fields.slur_filter_regexes || '').slice(0, 8000)); }
-- if (fields.slur_filter_nudge_message !== undefined) { updates.push('slur_filter_nudge_message = ?'); params.push(String(fields.slur_filter_nudge_message || '').slice(0, 800)); }
-- if (fields.slur_filter_disabled_categories !== undefined) { updates.push('slur_filter_disabled_categories = ?'); params.push(String(fields.slur_filter_disabled_categories || '[]').slice(0, 200)); }
-- if (fields.ip_approval_mode !== undefined) { updates.push('ip_approval_mode = ?'); params.push(fields.ip_approval_mode ? 1 : 0); }
-- if (fields.soundboard_enabled !== undefined) { updates.push('soundboard_enabled = ?'); params.push(fields.soundboard_enabled ? 1 : 0); }
-- if (fields.soundboard_allow_pitch !== undefined) { updates.push('soundboard_allow_pitch = ?'); params.push(fields.soundboard_allow_pitch ? 1 : 0); }
-- if (fields.soundboard_allow_speed !== undefined) { updates.push('soundboard_allow_speed = ?'); params.push(fields.soundboard_allow_speed ? 1 : 0); }
-- if (fields.soundboard_banned_ids !== undefined) { updates.push('soundboard_banned_ids = ?'); params.push(String(fields.soundboard_banned_ids || '').slice(0, 4000)); }
-- if (fields.viewer_auto_delete_enabled !== undefined) { updates.push('viewer_auto_delete_enabled = ?'); params.push(fields.viewer_auto_delete_enabled ? 1 : 0); }
-- if (fields.viewer_delete_all_enabled !== undefined) { updates.push('viewer_delete_all_enabled = ?'); params.push(fields.viewer_delete_all_enabled ? 1 : 0); }
-- if (fields.custom_emotes_enabled !== undefined) { updates.push('custom_emotes_enabled = ?'); params.push(fields.custom_emotes_enabled ? 1 : 0); }
-- if (fields.custom_sounds_enabled !== undefined) { updates.push('custom_sounds_enabled = ?'); params.push(fields.custom_sounds_enabled ? 1 : 0); }
-- if (fields.max_sound_seconds !== undefined) { updates.push('max_sound_seconds = ?'); params.push(Math.min(30, Math.max(1, Number(fields.max_sound_seconds) || 10))); }
-- if (fields.uploads_mods_only !== undefined) { updates.push('uploads_mods_only = ?'); params.push(fields.uploads_mods_only ? 1 : 0); }
-- if (fields.mods_can_edit_about !== undefined) { updates.push('mods_can_edit_about = ?'); params.push(fields.mods_can_edit_about ? 1 : 0); }
-- if (fields.emote_scale !== undefined) { updates.push('emote_scale = ?'); params.push(Math.min(300, Math.max(50, Number(fields.emote_scale) || 100))); }
-- if (fields.emote_size_min !== undefined) { updates.push('emote_size_min = ?'); params.push(Math.min(200, Math.max(25, Number(fields.emote_size_min) || 50))); }
-- if (fields.emote_size_max !== undefined) { updates.push('emote_size_max = ?'); params.push(Math.min(400, Math.max(50, Number(fields.emote_size_max) || 200))); }
-- if (fields.sounds_mods_only !== undefined) { updates.push('sounds_mods_only = ?'); params.push(fields.sounds_mods_only ? 1 : 0); }
-- if (fields.sound_min_speed !== undefined) { updates.push('sound_min_speed = ?'); params.push(Math.min(1, Math.max(0.1, Number(fields.sound_min_speed) || 0.5))); }
-- if (fields.sound_max_speed !== undefined) { updates.push('sound_max_speed = ?'); params.push(Math.min(5, Math.max(1, Number(fields.sound_max_speed) || 3.0))); }
-- if (fields.sound_min_pitch_cents !== undefined) { updates.push('sound_min_pitch_cents = ?'); params.push(Math.min(0, Math.max(-2400, Math.round(Number(fields.sound_min_pitch_cents) || -1200)))); }
-- if (fields.sound_max_pitch_cents !== undefined) { updates.push('sound_max_pitch_cents = ?'); params.push(Math.max(0, Math.min(2400, Math.round(Number(fields.sound_max_pitch_cents) || 1200)))); }
-- if (updates.length > 0) {
-- updates.push('updated_at = CURRENT_TIMESTAMP');
-- params.push(channelId);
-- run(`UPDATE channel_moderation_settings SET ${updates.join(', ')} WHERE channel_id = ?`, params);
-- }
-- } else {
-- run(
-- `INSERT INTO channel_moderation_settings (
-- channel_id, slow_mode_seconds, followers_only, emote_only,
-- allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours,
-- caps_percentage_limit, aggressive_filter, max_message_length,
-- slur_filter_enabled, slur_filter_use_builtin, slur_filter_terms, slur_filter_regexes, slur_filter_nudge_message, slur_filter_disabled_categories,
-- ip_approval_mode, soundboard_enabled, soundboard_allow_pitch, soundboard_allow_speed, soundboard_banned_ids,
-- viewer_auto_delete_enabled, viewer_delete_all_enabled,
-- custom_emotes_enabled, custom_sounds_enabled, max_sound_seconds, uploads_mods_only, emote_scale,
-- emote_size_min, emote_size_max, sounds_mods_only, mods_can_edit_about
-- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
-- [
-- channelId,
-- fields.slow_mode_seconds || 0,
-- fields.followers_only ? 1 : 0,
-- fields.emote_only ? 1 : 0,
-- fields.allow_anonymous !== undefined ? (fields.allow_anonymous ? 1 : 0) : 1,
-- fields.links_allowed !== undefined ? (fields.links_allowed ? 1 : 0) : 1,
-- fields.gifs_enabled !== undefined ? (fields.gifs_enabled ? 1 : 0) : 1,
-- Number(fields.account_age_gate_hours) || 0,
-- Number(fields.caps_percentage_limit) || 0,
-- fields.aggressive_filter ? 1 : 0,
-- Math.max(50, Number(fields.max_message_length) || 500),
-- fields.slur_filter_enabled ? 1 : 0,
-- fields.slur_filter_use_builtin !== undefined ? (fields.slur_filter_use_builtin ? 1 : 0) : 1,
-- String(fields.slur_filter_terms || '').slice(0, 4000),
-- String(fields.slur_filter_regexes || '').slice(0, 8000),
-- String(fields.slur_filter_nudge_message || '').slice(0, 800),
-- String(fields.slur_filter_disabled_categories || '[]').slice(0, 200),
-- fields.ip_approval_mode ? 1 : 0,
-- fields.soundboard_enabled !== undefined ? (fields.soundboard_enabled ? 1 : 0) : 1,
-- fields.soundboard_allow_pitch !== undefined ? (fields.soundboard_allow_pitch ? 1 : 0) : 1,
-- fields.soundboard_allow_speed !== undefined ? (fields.soundboard_allow_speed ? 1 : 0) : 1,
-- String(fields.soundboard_banned_ids || '').slice(0, 4000),
-- fields.viewer_auto_delete_enabled !== undefined ? (fields.viewer_auto_delete_enabled ? 1 : 0) : 1,
-- fields.viewer_delete_all_enabled !== undefined ? (fields.viewer_delete_all_enabled ? 1 : 0) : 1,
-- fields.custom_emotes_enabled !== undefined ? (fields.custom_emotes_enabled ? 1 : 0) : 1,
-- fields.custom_sounds_enabled !== undefined ? (fields.custom_sounds_enabled ? 1 : 0) : 1,
-- Math.min(30, Math.max(1, Number(fields.max_sound_seconds) || 10)),
-- fields.uploads_mods_only ? 1 : 0,
-- Math.min(300, Math.max(50, Number(fields.emote_scale) || 100)),
-- Math.min(200, Math.max(25, Number(fields.emote_size_min) || 50)),
-- Math.min(400, Math.max(50, Number(fields.emote_size_max) || 200)),
-- fields.sounds_mods_only ? 1 : 0,
-- fields.mods_can_edit_about ? 1 : 0,
-- ]
-- );
-- }
-- // tts_max_length is handled here (covers both the UPDATE and freshly-INSERTed row) so we
-- // don't have to thread it through the positional INSERT.
-- if (fields.tts_max_length !== undefined) {
-- try { run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]); } catch { /* */ }
-- }
-- // sub_only the same way (OpenVibe.Chat's database.js writes it identically once it owns the table).
-- if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
-- return getChannelModerationSettings(channelId);
--}
--
// ── Pastes ───────────────────────────────────────────────────
// They live in OpenVibe.Community (server/pastes-client.js, media-proxy/lookups.js). The legacy
// local pastes/paste_likes/paste_comments tables are frozen and unread, like vods/clips above.
@@@ -7674,7 +7183,6 @@ module.exports = {
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
recordViewerSample, getViewerTrend, getReadingSeries, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
-- setChannelAlertSound, getChannelAlertSoundsByUser,
// Users
getUserById, getUserByUsername, getUserByStreamKey, createUser, getOrCreateAnonGameUser,
// Managed Streams
@@@ -7727,10 +7235,6 @@ module.exports = {
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
// Chat
saveChatMessage, searchChatMessages, getUserChatHistory, getChatSamplesInChannel,
-- // Chat AI summaries
-- getMaxChatMessageId, countChatMessagesSince, getChatMessagesForAi, getNthRecentChatTs,
-- getChatAiSummary, getChatAiSummaries, upsertChatAiSummary, getUsersNeedingChatAi,
-- addChatTimelineEvents, getChatTimelineEvents,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
getTtsVoiceOverride, setTtsVoiceOverride, deleteTtsVoiceOverride,
// Profiles
@@@ -7770,9 +7274,6 @@ module.exports = {
getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache, forgiveBan,
-- // Emotes
-- createEmote, getEmoteById, getEmotesByUser, getGlobalEmotes, getChannelEmotes, updateEmote, setEmoteMedia,
-- deleteEmote, getEmoteByCode, countUserEmotes, countChannelEmotes, getChannelEmoteByCode,
createChannelSound, setChannelSoundEmote, getChannelSounds, getChannelSoundByCommand, getChannelSoundById, renameChannelSoundCommand, updateChannelSoundEmoteRefs,
countChannelSounds, countChannelSoundsByUploader, deleteChannelSound,
getAiChatbotConfig, upsertAiChatbotConfig,
@@@ -7792,11 +7293,6 @@ module.exports = {
getChatReplay,
// Channel lookup
getChannelById,
-- // Channel Moderators
-- isChannelModerator, addChannelModerator, removeChannelModerator,
-- getChannelModerators, getChannelsByModerator,
-- // Channel Moderation Settings
-- getChannelModerationSettings, upsertChannelModerationSettings,
getUserTotalGameLevel, getLegacyGameProfile,
// Anon IP Mappings
getOrCreateAnonNum, getAnonFirstSeen, loadAnonMappings,
@@@ -7812,8 +7308,8 @@ module.exports = {
approveAllFromIp, denyAllFromIp,
// Hidden Relay Users
hideRelayUser, isRelayUserHidden, unhideRelayUser, unhideRelayUserByIdentity, getHiddenRelayUsers, recordRelayUser, getRelayUser,
-- getRelayUserByRowid, getRelayUserChatHistory, getRelayChatMessagesForAi, getRelayUsersNeedingChatAi,
-- anonSubjectId, getAnonMeta, getAnonChatHistory, getAnonChatMessagesForAi, getAnonsNeedingChatAi,
++ getRelayUserByRowid, getRelayUserChatHistory,
++ anonSubjectId, getAnonMeta, getAnonChatHistory,
// IP Tracking
logIp, getIpsByUser, getUsersByIp, getLinkedAccounts, getLinkedAccountsByAnon,
getLatestIpForUser, getLatestIpForAnon, getIpLog, banAllAccountsOnIp,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex c5f0cfd3..3a6ac2bb 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -1,6 +1,20 @@
-- OpenVibe.Live Database Schema
-- SQLite3
++-- ── Deletion ledger ─────────────────────────────────────────────────────────
++-- OpenVibe.Chat owns the six staged chat tables (roadmap T3) and this release deleted Live's
++-- staged-table machinery (server/chat/chat-tables*.js, the /internal/chat-tables routes, Live's
++-- chat-AI job and the old emote/moderation routes). The tables stay one release for N-1
++-- (ADR-016): the previous release's SQL still prepares against them (test/n-1.test.js).
++-- channel_moderators unused since T3 N+1; dropped in the next release.
++-- channel_moderation_settings unused since T3 N+1; dropped in the next release.
++-- emotes unused since T3 N+1; dropped in the next release.
++-- user_tags unused since T3 N+1; dropped in the next release.
++-- chat_ai_summaries unused since T3 N+1; dropped in the next release.
++-- chat_timeline_events unused since T3 N+1; dropped in the next release.
++-- chat_staged_outbox unused since T3 N+1; dropped in the next release.
++-- chat_dual_read_stats unused since T3 N+1; dropped in the next release.
++
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -551,6 +565,29 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
++-- ── Staged-table bookkeeping, kept one release for N-1 (see the deletion ledger above) ──
++-- OpenVibe.Chat owns the six staged chat tables and the machinery that used these two is gone
++-- (roadmap T3). The previous release still creates and prepares against them, so they stay here:
++-- only their writer was deleted, not the tables (ADR-016).
++CREATE TABLE IF NOT EXISTS chat_staged_outbox (
++ seq INTEGER PRIMARY KEY AUTOINCREMENT,
++ tbl TEXT NOT NULL,
++ op TEXT NOT NULL,
++ pk TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE TABLE IF NOT EXISTS chat_dual_read_stats (
++ tbl TEXT PRIMARY KEY,
++ compared INTEGER NOT NULL DEFAULT 0,
++ matched INTEGER NOT NULL DEFAULT 0,
++ mismatched INTEGER NOT NULL DEFAULT 0,
++ inconclusive INTEGER NOT NULL DEFAULT 0,
++ errors INTEGER NOT NULL DEFAULT 0,
++ last_mismatch_at DATETIME,
++ last_mismatch TEXT,
++ since DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++
-- ═══════════════════════════════════════════════════════════════
-- Site Settings (key/value store for platform configuration)
-- ═══════════════════════════════════════════════════════════════
ddiff --git a/server/emotes/routes.js b/server/emotes/routes.js
ddeleted file mode 100644
iindex 6d7a8dad..00000000
---- a/server/emotes/routes.js
++++ /dev/null
@@@ -1,786 +0,0 @@
--/**
-- * OpenVibe.Live — Emote API Routes
-- *
-- * GET /api/emotes/global - Global custom emotes
-- * GET /api/emotes/channel/:userId - Channel emotes for a streamer
-- * GET /api/emotes/mine - My emotes (auth required)
-- * POST /api/emotes - Upload a custom emote (auth required)
-- * DELETE /api/emotes/:id - Delete an emote (auth required)
-- * GET /api/emotes/file/:filename - Serve emote image file
-- * GET /api/emotes/ffz - Proxy + cache FrankerFaceZ global emotes
-- * GET /api/emotes/bttv - Proxy + cache BetterTTV global emotes
-- * GET /api/emotes/search - Search FFZ emotes by term
-- * GET /api/emotes/all/:streamId - All emotes available in a stream context
-- */
--const express = require('express');
--const path = require('path');
--const fs = require('fs');
--const multer = require('multer');
--const db = require('../db/database');
--const { requireAuth, optionalAuth } = require('../auth/auth');
--const permissions = require('../auth/permissions');
--const config = require('../config');
--// Emote rows are written wherever the table's authority is (Live or OpenVibe.Chat, chat-tables.js).
--const chatTables = require('../chat/chat-tables');
--
--const router = express.Router();
--
--// ── Emote file upload via multer ─────────────────────────────
--const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif' };
--const emoteStorage = multer.diskStorage({
-- destination: (req, file, cb) => {
-- const emoteDir = path.resolve(config.emotes.path);
-- if (!fs.existsSync(emoteDir)) fs.mkdirSync(emoteDir, { recursive: true });
-- cb(null, emoteDir);
-- },
-- filename: (req, file, cb) => {
-- const ext = MIME_TO_EXT[file.mimetype] || '.png';
-- cb(null, `emote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
-- },
--});
--const emoteUpload = multer({
-- storage: emoteStorage,
-- limits: { fileSize: config.emotes.maxSizeKb * 1024 },
-- fileFilter: (req, file, cb) => {
-- const allowed = ['image/png', 'image/gif', 'image/webp', 'image/jpeg', 'image/avif'];
-- if (allowed.includes(file.mimetype)) {
-- cb(null, true);
-- } else {
-- cb(new Error('Only PNG, GIF, WebP, AVIF, and JPEG images are allowed'));
-- }
-- },
--});
--
--// Per-user emote-upload rate limit (sliding window) so a single account can't mass-spam uploads
--// across many channels. Keyed by user id, so it holds even across IPs. Filling one channel (30)
--// in a session is fine; hundreds of rapid uploads are not.
--const EMOTE_UPLOAD_WINDOW_MS = 15 * 60 * 1000;
--const EMOTE_UPLOAD_MAX = 40;
--const _emoteUploadTimes = new Map(); // userId → recent upload timestamps
--function emoteUploadRateLimited(userId) {
-- const now = Date.now();
-- const arr = (_emoteUploadTimes.get(userId) || []).filter((t) => now - t < EMOTE_UPLOAD_WINDOW_MS);
-- if (arr.length >= EMOTE_UPLOAD_MAX) { _emoteUploadTimes.set(userId, arr); return true; }
-- arr.push(now);
-- _emoteUploadTimes.set(userId, arr);
-- return false;
--}
--
--// ══════════════════════════════════════════════════════════════
--// FFZ / BTTV CACHE
--// ══════════════════════════════════════════════════════════════
--
--let ffzCache = { data: null, ts: 0 };
--let bttvCache = { data: null, ts: 0 };
--let sevenTvCache = { data: null, ts: 0 };
--let ffzSearchCache = new Map(); // query → { data, ts }
--
--async function fetchJSON(url) {
-- // Use native fetch (Node 18+)
-- const res = await fetch(url, {
-- headers: { 'User-Agent': 'OpenVibe.Live/1.0 (emote-proxy)' },
-- signal: AbortSignal.timeout(8000),
-- });
-- if (!res.ok) throw new Error(`HTTP ${res.status}`);
-- return res.json();
--}
--
--/**
-- * Parse FFZ API response into a flat emote array
-- */
--function parseFfzSets(data) {
-- const emotes = [];
-- const sets = data.sets || {};
-- for (const setId of Object.keys(sets)) {
-- const set = sets[setId];
-- for (const e of (set.emoticons || [])) {
-- if (e.hidden) continue;
-- if (e.modifier) continue; // skip modifier effects
-- emotes.push({
-- id: `ffz-${e.id}`,
-- code: e.name,
-- url: e.urls['2'] || e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
-- url_1x: e.urls['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
-- url_2x: e.urls['2'] || e.urls['1'],
-- url_4x: e.urls['4'] || e.urls['2'] || e.urls['1'],
-- width: e.width || 28,
-- height: e.height || 28,
-- animated: false,
-- source: 'ffz',
-- });
-- }
-- }
-- return emotes;
--}
--
--/**
-- * Parse BTTV API response into a flat emote array
-- */
--function parseBttvEmotes(data) {
-- return (Array.isArray(data) ? data : []).map(e => ({
-- id: `bttv-${e.id}`,
-- code: e.code,
-- url: `https://cdn.betterttv.net/emote/${e.id}/2x`,
-- url_1x: `https://cdn.betterttv.net/emote/${e.id}/1x`,
-- url_2x: `https://cdn.betterttv.net/emote/${e.id}/2x`,
-- url_4x: `https://cdn.betterttv.net/emote/${e.id}/3x`,
-- width: 28,
-- height: 28,
-- animated: e.imageType === 'gif' || e.animated === true,
-- source: 'bttv',
-- }));
--}
--
--function parseBttvEmotes(data) {
-- return (Array.isArray(data) ? data : []).map(e => ({
-- id: `bttv-${e.id}`,
-- code: e.code,
-- url: `https://cdn.betterttv.net/emote/${e.id}/2x`,
-- url_1x: `https://cdn.betterttv.net/emote/${e.id}/1x`,
-- url_2x: `https://cdn.betterttv.net/emote/${e.id}/2x`,
-- url_4x: `https://cdn.betterttv.net/emote/${e.id}/3x`,
-- width: 28,
-- height: 28,
-- animated: e.imageType === 'gif' || e.animated === true,
-- source: 'bttv',
-- }));
--}
--
--/**
-- * Parse 7TV global emote set into a flat emote array
-- */
--function parse7tvEmotes(data) {
-- const emotes = data?.emotes || [];
-- return emotes.map(e => {
-- const host = e.data?.host;
-- const baseUrl = host?.url || '';
-- const files = host?.files || [];
-- // Prefer 2x webp, fall back to 1x
-- const f2x = files.find(f => f.name === '2x.webp') || files.find(f => f.name === '2x.avif') || files.find(f => f.name === '1x.webp') || files[0];
-- const f1x = files.find(f => f.name === '1x.webp') || files.find(f => f.name === '1x.avif') || files[0];
-- const f4x = files.find(f => f.name === '4x.webp') || files.find(f => f.name === '3x.webp') || f2x;
-- const animated = !!(e.data?.animated);
-- return {
-- id: `7tv-${e.id}`,
-- code: e.name,
-- url: f2x ? `https:${baseUrl}/${f2x.name}` : '',
-- url_1x: f1x ? `https:${baseUrl}/${f1x.name}` : '',
-- url_2x: f2x ? `https:${baseUrl}/${f2x.name}` : '',
-- url_4x: f4x ? `https:${baseUrl}/${f4x.name}` : '',
-- width: f2x?.width || 28,
-- height: f2x?.height || 28,
-- animated,
-- source: '7tv',
-- };
-- }).filter(e => e.url);
--}
--
--/**
-- * Default emote collection — popular community emotes everyone recognizes.
-- * These use FFZ / BTTV / 7TV CDN URLs for known emote IDs.
-- */
--const DEFAULT_EMOTES = [
-- // FFZ classics
-- { id: 'def-lul', code: 'LUL', url: 'https://cdn.frankerfacez.com/emote/38010/2', source: 'defaults', animated: false },
-- { id: 'def-lulw', code: 'LULW', url: 'https://cdn.frankerfacez.com/emote/139407/2', source: 'defaults', animated: false },
-- { id: 'def-kekw', code: 'KEKW', url: 'https://cdn.frankerfacez.com/emote/381875/2', source: 'defaults', animated: false },
-- { id: 'def-omegalul', code: 'OMEGALUL', url: 'https://cdn.frankerfacez.com/emote/128054/2', source: 'defaults', animated: false },
-- { id: 'def-pepehands', code: 'PepeHands', url: 'https://cdn.frankerfacez.com/emote/231552/2', source: 'defaults', animated: false },
-- { id: 'def-copium', code: 'Copium', url: 'https://cdn.frankerfacez.com/emote/540942/2', source: 'defaults', animated: false },
-- { id: 'def-monkas', code: 'monkaS', url: 'https://cdn.frankerfacez.com/emote/130762/2', source: 'defaults', animated: false },
-- { id: 'def-ez', code: 'EZ', url: 'https://cdn.frankerfacez.com/emote/425688/2', source: 'defaults', animated: false },
-- { id: 'def-pog', code: 'Pog', url: 'https://cdn.frankerfacez.com/emote/210748/2', source: 'defaults', animated: false },
-- { id: 'def-poggers', code: 'Poggers', url: 'https://cdn.frankerfacez.com/emote/214681/2', source: 'defaults', animated: false },
-- // BTTV classics
-- { id: 'def-pogu', code: 'PogU', url: 'https://cdn.betterttv.net/emote/5e4e7a1f08b4447d56a92967/2x', source: 'defaults', animated: false },
-- { id: 'def-peped', code: 'PepeD', url: 'https://cdn.betterttv.net/emote/5b1740221c5a6065a7bad4b5/2x', source: 'defaults', animated: true },
-- { id: 'def-catjam', code: 'catJAM', url: 'https://cdn.betterttv.net/emote/5f1b0186cf6d2144653d2970/2x', source: 'defaults', animated: true },
-- { id: 'def-sadge', code: 'Sadge', url: 'https://cdn.betterttv.net/emote/5e0fa9d40550d42106b8a489/2x', source: 'defaults', animated: false },
-- { id: 'def-pepega', code: 'Pepega', url: 'https://cdn.betterttv.net/emote/5aca62163e290877a25481ad/2x', source: 'defaults', animated: false },
-- { id: 'def-5head', code: '5Head', url: 'https://cdn.betterttv.net/emote/5d6096974932b21d9c332904/2x', source: 'defaults', animated: false },
-- { id: 'def-widehard', code: 'widepeepohappy', url: 'https://cdn.betterttv.net/emote/5e1a76dd8af14b5f1b438c04/2x', source: 'defaults', animated: false },
-- { id: 'def-monkaw', code: 'monkaW', url: 'https://cdn.betterttv.net/emote/59ca6551b27c823d5b1fd872/2x', source: 'defaults', animated: false },
-- { id: 'def-pepelaugh', code: 'PepeLaugh', url: 'https://cdn.betterttv.net/emote/5c548025009a2e73916b3a37/2x', source: 'defaults', animated: false },
-- { id: 'def-modtime', code: 'modCheck', url: 'https://cdn.betterttv.net/emote/5d7eefb7c0652668c9e4d394/2x', source: 'defaults', animated: true },
-- { id: 'def-clap', code: 'CLAP', url: 'https://cdn.betterttv.net/emote/55b6f480e66682f576dd94f5/2x', source: 'defaults', animated: false },
-- { id: 'def-gg', code: 'GGEZ', url: 'https://cdn.frankerfacez.com/emote/703645/2', source: 'defaults', animated: false },
-- { id: 'def-based', code: 'BASED', url: 'https://cdn.frankerfacez.com/emote/768564/2', source: 'defaults', animated: false },
-- { id: 'def-peepo', code: 'peepoHappy', url: 'https://cdn.betterttv.net/emote/5a16ee718c22a247ead62d4a/2x', source: 'defaults', animated: false },
-- { id: 'def-mods', code: 'MODS', url: 'https://cdn.betterttv.net/emote/603451b77c74605395f3295d/2x', source: 'defaults', animated: true },
--].map(e => ({ ...e, width: 28, height: 28 }));
--
--// ══════════════════════════════════════════════════════════════
--// ROUTES
--// ══════════════════════════════════════════════════════════════
--
--// ── Global custom emotes ─────────────────────────────────────
--router.get('/global', (req, res) => {
-- try {
-- const emotes = db.getGlobalEmotes().map(e => ({
-- id: `custom-${e.id}`,
-- code: e.code,
-- url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
-- animated: !!e.animated,
-- width: e.width,
-- height: e.height,
-- source: 'custom',
-- owner: e.username,
-- }));
-- res.json({ emotes });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load global emotes' });
-- }
--});
--
--// ── Channel emotes ───────────────────────────────────────────
--router.get('/channel/:userId', (req, res) => {
-- try {
-- const userId = parseInt(req.params.userId);
-- const emotes = db.getChannelEmotes(userId).map(e => ({
-- id: `custom-${e.id}`,
-- emote_id: e.id,
-- code: e.code,
-- url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
-- animated: !!e.animated,
-- width: e.width,
-- height: e.height,
-- source: 'channel',
-- owner: e.username,
-- uploader: e.uploader_display_name || e.uploader_username || e.username,
-- uploader_id: e.user_id,
-- }));
-- res.json({ emotes });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load channel emotes' });
-- }
--});
--
--// ── My emotes (for dashboard management) ─────────────────────
--router.get('/mine', requireAuth, (req, res) => {
-- try {
-- const emotes = db.getEmotesByUser(req.user.id).map(e => ({
-- id: e.id,
-- code: e.code,
-- url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
-- animated: !!e.animated,
-- width: e.width,
-- height: e.height,
-- is_global: !!e.is_global,
-- created_at: e.created_at,
-- }));
-- // Cap is per-channel now: your own emotes + any viewer uploads on your channel, up to max.
-- const count = db.countChannelEmotes(req.user.id);
-- const max = config.emotes.maxPerChannel;
-- res.json({ emotes, count, max });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load emotes' });
-- }
--});
--
--// ── Upload a custom emote ────────────────────────────────────
--router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
-- try {
-- if (!req.file) return res.status(400).json({ error: 'No image file uploaded' });
--
-- // Anti-spam: cap how many emotes one account can upload per window (across all channels).
-- if (emoteUploadRateLimited(req.user.id)) {
-- fs.unlinkSync(req.file.path);
-- return res.status(429).json({ error: `Too many emote uploads — please wait a bit before uploading more.` });
-- }
--
-- const code = (req.body.code || '').trim();
-- if (!code || code.length < 2 || code.length > 32) {
-- fs.unlinkSync(req.file.path);
-- return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
-- }
-- if (!/^[a-zA-Z0-9_]+$/.test(code)) {
-- fs.unlinkSync(req.file.path);
-- return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
-- }
--
-- // Optional target channel: a viewer uploading an emote FOR a streamer's channel.
-- // channel_id is the streamer's user id; stream_id is resolved to its owner.
-- let channelOwnerId = parseInt(req.body.channel_id) || null;
-- if (!channelOwnerId && req.body.stream_id) {
-- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;
-- }
-- const isChannelUpload = channelOwnerId && channelOwnerId !== req.user.id;
--
-- if (isChannelUpload) {
-- const channel = db.getChannelByUserId(channelOwnerId);
-- if (!channel) {
-- fs.unlinkSync(req.file.path);
-- return res.status(404).json({ error: 'Channel not found' });
-- }
-- const settings = db.getChannelModerationSettings(channel.id);
-- const isMod = await permissions.canModerateChannel(req.user, channel.id);
-- if (!settings.custom_emotes_enabled && !isMod) {
-- fs.unlinkSync(req.file.path);
-- return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
-- }
-- if (settings.uploads_mods_only && !isMod) {
-- fs.unlinkSync(req.file.path);
-- return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });
-- }
-- // Per-channel cap only — anyone can upload until the channel is full (then the
-- // streamer removes some). No per-uploader sub-limit.
-- if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {
-- fs.unlinkSync(req.file.path);
-- return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });
-- }
-- // Unique code within the channel (any uploader)
-- if (db.getChannelEmoteByCode(channelOwnerId, code)) {
-- fs.unlinkSync(req.file.path);
-- return res.status(409).json({ error: `This channel already has an emote named "${code}".` });
-- }
-- } else {
-- // Uploading to your OWN channel — counts toward the same per-channel budget (your own
-- // emotes + any viewer uploads on your channel), capped at maxPerChannel.
-- if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {
-- fs.unlinkSync(req.file.path);
-- return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });
-- }
-- // Uniqueness is per-channel: only clash with emotes on YOUR OWN channel
-- // (channel_owner_id IS NULL), not ones you uploaded to other people's channels.
-- const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);
-- if (existing) {
-- fs.unlinkSync(req.file.path);
-- return res.status(409).json({ error: `You already have an emote named "${code}"` });
-- }
-- }
--
-- const animated = req.file.mimetype === 'image/gif' || req.file.mimetype === 'image/webp';
-- const isGlobal = req.body.is_global === 'true' && permissions.can(req.user, 'staff.assets.manage');
--
-- // Per-emote display size (percent), clamped to the channel's configured range.
-- let sizeMin = 25, sizeMax = 400;
-- if (channelOwnerId) {
-- try {
-- const ch = db.getChannelByUserId(channelOwnerId);
-- const st = ch ? db.getChannelModerationSettings(ch.id) : null;
-- if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
-- } catch { /* defaults */ }
-- }
-- const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
--
-- const result = await chatTables.write('createEmote', {
-- user_id: req.user.id,
-- code,
-- url: req.file.path,
-- animated,
-- width: parseInt(req.body.width) || 28,
-- height: parseInt(req.body.height) || 28,
-- is_global: isGlobal,
-- channel_owner_id: isChannelUpload ? channelOwnerId : null,
-- size,
-- });
--
-- // Refresh viewers' emote pickers live (no reload) for channel emotes.
-- if (isChannelUpload) {
-- try { require('../chat/chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
-- }
--
-- try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }
-- res.json({
-- emote: {
-- id: result.lastInsertRowid,
-- code,
-- url: `/api/emotes/file/${path.basename(req.file.path)}`,
-- animated,
-- channel_id: isChannelUpload ? channelOwnerId : undefined,
-- },
-- });
-- } catch (err) {
-- console.error('[Emotes] Upload error:', err);
-- if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} }
-- if (err && /UNIQUE constraint/i.test(err.message || '')) {
-- // (`code` is the try block's; an async handler must not throw from here)
-- const code = String((req.body && req.body.code) || '').trim();
-- return res.status(409).json({ error: `This channel already has an emote named "${code}" — pick a different code for this channel.` });
-- }
-- res.status(500).json({ error: 'Failed to upload emote' });
-- }
--});
--
--// ── Multer error handler ─────────────────────────────────────
--router.use((err, req, res, next) => {
-- if (err.code === 'LIMIT_FILE_SIZE') {
-- return res.status(400).json({ error: `File too large (max ${config.emotes.maxSizeKb}KB)` });
-- }
-- if (err.message && err.message.includes('Only')) {
-- return res.status(400).json({ error: err.message });
-- }
-- console.error('[Emotes] Middleware error:', err);
-- res.status(500).json({ error: 'Emote upload failed' });
--});
--
--// ── Delete an emote ──────────────────────────────────────────
--// ── Edit an emote in place (rename code / change display size) ──
--// Same permission model as delete: uploader, admin, or channel mod/owner.
--router.patch('/:id', requireAuth, async (req, res) => {
-- try {
-- const emote = db.getEmoteById(req.params.id);
-- if (!emote) return res.status(404).json({ error: 'Emote not found' });
-- let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
-- if (!allowed) {
-- const ownerId = emote.channel_owner_id || emote.user_id;
-- const channel = db.getChannelByUserId(ownerId);
-- if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
-- }
-- if (!allowed) return res.status(403).json({ error: 'Not your emote' });
--
-- const patch = {};
-- if (req.body.code !== undefined) {
-- const code = String(req.body.code || '').trim();
-- if (!code || code.length < 2 || code.length > 32) {
-- return res.status(400).json({ error: 'Emote code must be 2-32 characters' });
-- }
-- if (!/^[a-zA-Z0-9_]+$/.test(code)) {
-- return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
-- }
-- // No duplicates within the same channel's emote set.
-- const scopeOwner = emote.channel_owner_id || emote.user_id;
-- const clash = (db.getChannelEmotes(scopeOwner) || [])
-- .find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());
-- if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });
-- patch.code = code;
-- }
-- if (req.body.size !== undefined) {
-- // Clamp to the channel's configured render range, like upload does.
-- let sizeMin = 25, sizeMax = 400;
-- try {
-- const ownerId = emote.channel_owner_id || emote.user_id;
-- const channel = db.getChannelByUserId(ownerId);
-- const st = channel ? db.getChannelModerationSettings(channel.id) : null;
-- if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
-- } catch { /* defaults */ }
-- patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
-- }
-- if (patch.code === undefined && patch.size === undefined) {
-- return res.status(400).json({ error: 'Nothing to change' });
-- }
--
-- await chatTables.write('updateEmote', emote.id, patch);
-- // Sound commands attach this emote by code — carry the rename over.
-- if (patch.code && patch.code !== emote.code) {
-- try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }
-- }
-- if (emote.channel_owner_id) {
-- try { require('../chat/chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
-- }
-- res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update emote' });
-- }
--});
--
--router.delete('/:id', requireAuth, async (req, res) => {
-- try {
-- const emote = db.getEmoteById(req.params.id);
-- if (!emote) return res.status(404).json({ error: 'Emote not found' });
-- // Allowed: the uploader, an admin, or a mod/owner of the channel the emote belongs to.
-- let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
-- if (!allowed) {
-- const ownerId = emote.channel_owner_id || emote.user_id;
-- const channel = db.getChannelByUserId(ownerId);
-- if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
-- }
-- if (!allowed) {
-- return res.status(403).json({ error: 'Not your emote' });
-- }
--
-- // The row first: if that write fails, the file is still there.
-- await chatTables.write('deleteEmote', emote.id);
-- if (emote.url && fs.existsSync(emote.url)) {
-- fs.unlinkSync(emote.url);
-- }
-- try { require('../media-proxy/asset-sync').removeAsset(emote.media_asset_id); } catch { /* */ }
-- if (emote.channel_owner_id) {
-- try { require('../chat/chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
-- }
-- res.json({ message: 'Emote deleted' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete emote' });
-- }
--});
--
--// ── Serve emote image files ──────────────────────────────────
--router.get('/file/:filename', (req, res) => {
-- try {
-- const filename = path.basename(req.params.filename);
-- const filePath = path.resolve(config.emotes.path, filename);
--
-- if (!fs.existsSync(filePath)) {
-- return res.status(404).json({ error: 'Emote file not found' });
-- }
--
-- const ext = path.extname(filename).toLowerCase();
-- const mimeTypes = {
-- '.png': 'image/png',
-- '.gif': 'image/gif',
-- '.webp': 'image/webp',
-- '.jpg': 'image/jpeg',
-- '.jpeg': 'image/jpeg',
-- '.avif': 'image/avif',
-- };
--
-- res.setHeader('Content-Type', mimeTypes[ext] || 'image/png');
-- res.setHeader('Cache-Control', 'public, max-age=86400');
-- fs.createReadStream(filePath).pipe(res);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to serve emote' });
-- }
--});
--
--// ── FFZ global emotes (cached proxy) ─────────────────────────
--router.get('/ffz', async (req, res) => {
-- try {
-- const now = Date.now();
-- if (ffzCache.data && (now - ffzCache.ts) < config.emotes.ffzCacheTtl * 1000) {
-- return res.json({ emotes: ffzCache.data, cached: true });
-- }
--
-- const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
-- const emotes = parseFfzSets(data);
-- ffzCache = { data: emotes, ts: now };
-- res.json({ emotes, cached: false });
-- } catch (err) {
-- // Return stale cache if available
-- if (ffzCache.data) return res.json({ emotes: ffzCache.data, cached: true, stale: true });
-- res.status(502).json({ error: 'Failed to fetch FFZ emotes', emotes: [] });
-- }
--});
--
--// ── BTTV global emotes (cached proxy) ────────────────────────
--router.get('/bttv', async (req, res) => {
-- try {
-- const now = Date.now();
-- if (bttvCache.data && (now - bttvCache.ts) < config.emotes.bttvCacheTtl * 1000) {
-- return res.json({ emotes: bttvCache.data, cached: true });
-- }
--
-- const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
-- const emotes = parseBttvEmotes(data);
-- bttvCache = { data: emotes, ts: now };
-- res.json({ emotes, cached: false });
-- } catch (err) {
-- if (bttvCache.data) return res.json({ emotes: bttvCache.data, cached: true, stale: true });
-- res.status(502).json({ error: 'Failed to fetch BTTV emotes', emotes: [] });
-- }
--});
--
--// ── 7TV global emotes (cached proxy) ─────────────────────────
--router.get('/7tv', async (req, res) => {
-- try {
-- const now = Date.now();
-- if (sevenTvCache.data && (now - sevenTvCache.ts) < config.emotes.sevenTvCacheTtl * 1000) {
-- return res.json({ emotes: sevenTvCache.data, cached: true });
-- }
--
-- const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
-- const emotes = parse7tvEmotes(data);
-- sevenTvCache = { data: emotes, ts: now };
-- res.json({ emotes, cached: false });
-- } catch (err) {
-- if (sevenTvCache.data) return res.json({ emotes: sevenTvCache.data, cached: true, stale: true });
-- res.status(502).json({ error: 'Failed to fetch 7TV emotes', emotes: [] });
-- }
--});
--
--// ── Default emotes ───────────────────────────────────────────
--router.get('/defaults', (req, res) => {
-- res.json({ emotes: DEFAULT_EMOTES });
--});
--
--// ── Emote source preferences ────────────────────────────────
--router.get('/sources', requireAuth, (req, res) => {
-- try {
-- const channel = db.ensureChannel(req.user.id);
-- let sources = { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true };
-- try { sources = JSON.parse(channel.emote_sources || '{}'); } catch (err) { console.warn('[Emotes] Source parse error:', err.message); /* use defaults */ }
-- // Ensure all keys exist
-- for (const key of ['defaults', 'custom', 'ffz', 'bttv', '7tv']) {
-- if (sources[key] === undefined) sources[key] = true;
-- }
-- res.json({ sources });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load emote sources' });
-- }
--});
--
--router.put('/sources', requireAuth, (req, res) => {
-- try {
-- const sources = {};
-- for (const key of ['defaults', 'custom', 'ffz', 'bttv', '7tv']) {
-- sources[key] = req.body[key] !== false && req.body[key] !== 'false';
-- }
-- db.updateChannel(req.user.id, { emote_sources: JSON.stringify(sources) });
-- res.json({ sources, message: 'Emote sources updated' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update emote sources' });
-- }
--});
--
--// ── Search FFZ emotes ────────────────────────────────────────
--router.get('/search', async (req, res) => {
-- try {
-- const q = (req.query.q || '').trim();
-- if (!q || q.length < 2) return res.json({ emotes: [] });
--
-- const cacheKey = q.toLowerCase();
-- const cached = ffzSearchCache.get(cacheKey);
-- const now = Date.now();
-- if (cached && (now - cached.ts) < config.emotes.ffzCacheTtl * 1000) {
-- return res.json({ emotes: cached.data, cached: true });
-- }
--
-- const data = await fetchJSON(`https://api.frankerfacez.com/v1/emotes?q=${encodeURIComponent(q)}&per_page=50&sort=count-desc`);
-- const emotes = (data.emoticons || []).filter(e => !e.hidden && !e.modifier).map(e => ({
-- id: `ffz-${e.id}`,
-- code: e.name,
-- url: e.urls?.['2'] || e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
-- url_1x: e.urls?.['1'] || `https://cdn.frankerfacez.com/emote/${e.id}/1`,
-- url_2x: e.urls?.['2'] || e.urls?.['1'],
-- animated: false,
-- source: 'ffz',
-- usage_count: e.usage_count || 0,
-- }));
--
-- ffzSearchCache.set(cacheKey, { data: emotes, ts: now });
-- // Prune old cache entries
-- if (ffzSearchCache.size > 200) {
-- const oldest = [...ffzSearchCache.entries()].sort((a, b) => a[1].ts - b[1].ts).slice(0, 100);
-- for (const [key] of oldest) ffzSearchCache.delete(key);
-- }
--
-- res.json({ emotes, cached: false });
-- } catch (err) {
-- res.status(502).json({ error: 'Search failed', emotes: [] });
-- }
--});
--
--// ── All emotes for a stream context ──────────────────────────
--router.get('/all/:streamId', optionalAuth, async (req, res) => {
-- try {
-- const streamId = parseInt(req.params.streamId);
-- const stream = streamId ? db.getStreamById(streamId) : null;
-- let streamUserId = stream ? stream.user_id : null;
-- // Fallback: resolve the channel by ?channel=<username> when there's no live stream
-- // context (e.g. the channel-wide chat overlay uses streamId 0) so a channel's custom
-- // emotes still load and render instead of showing as plain text.
-- if (!streamUserId && req.query.channel) {
-- try { const u = db.getUserByUsername(String(req.query.channel)); if (u) streamUserId = u.id; } catch { /* */ }
-- }
--
-- // Load channel's emote source preferences
-- let sources = { defaults: true, custom: true, ffz: true, bttv: true, '7tv': true };
-- if (streamUserId) {
-- const channel = db.getChannelByUserId(streamUserId);
-- if (channel && channel.emote_sources) {
-- try { sources = JSON.parse(channel.emote_sources); } catch { /* use defaults */ }
-- }
-- }
--
-- // Defaults
-- const defaults = sources.defaults !== false ? DEFAULT_EMOTES : [];
--
-- // Custom global emotes
-- let globalCustom = [];
-- let channelEmotes = [];
-- if (sources.custom !== false) {
-- globalCustom = db.getGlobalEmotes().map(e => ({
-- id: `custom-${e.id}`,
-- code: e.code,
-- url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
-- animated: !!e.animated,
-- width: e.width,
-- height: e.height,
-- size: e.size || 100,
-- source: 'custom',
-- owner: e.username,
-- }));
--
-- if (streamUserId) {
-- channelEmotes = db.getChannelEmotes(streamUserId).filter(e => !e.is_global).map(e => ({
-- id: `custom-${e.id}`,
-- emote_id: e.id,
-- code: e.code,
-- url: e.media_url || `/api/emotes/file/${path.basename(e.url)}`,
-- animated: !!e.animated,
-- width: e.width,
-- height: e.height,
-- size: e.size || 100,
-- source: 'channel',
-- owner: e.username,
-- uploader: e.uploader_display_name || e.uploader_username || e.username,
-- uploader_id: e.user_id,
-- }));
-- }
-- }
--
-- // FFZ (use cache or fetch)
-- let ffzEmotes = [];
-- if (sources.ffz !== false) {
-- ffzEmotes = ffzCache.data || [];
-- if (!ffzEmotes.length) {
-- try {
-- const data = await fetchJSON('https://api.frankerfacez.com/v1/set/global');
-- ffzEmotes = parseFfzSets(data);
-- ffzCache = { data: ffzEmotes, ts: Date.now() };
-- } catch { /* use empty */ }
-- }
-- }
--
-- // BTTV (use cache or fetch)
-- let bttvEmotes = [];
-- if (sources.bttv !== false) {
-- bttvEmotes = bttvCache.data || [];
-- if (!bttvEmotes.length) {
-- try {
-- const data = await fetchJSON('https://api.betterttv.net/3/cached/emotes/global');
-- bttvEmotes = parseBttvEmotes(data);
-- bttvCache = { data: bttvEmotes, ts: Date.now() };
-- } catch { /* use empty */ }
-- }
-- }
--
-- // 7TV (use cache or fetch)
-- let sevenTvEmotes = [];
-- if (sources['7tv'] !== false) {
-- sevenTvEmotes = sevenTvCache.data || [];
-- if (!sevenTvEmotes.length) {
-- try {
-- const data = await fetchJSON('https://7tv.io/v3/emote-sets/global');
-- sevenTvEmotes = parse7tvEmotes(data);
-- sevenTvCache = { data: sevenTvEmotes, ts: Date.now() };
-- } catch { /* use empty */ }
-- }
-- }
--
-- // The streamer's overall emote scale (a multiplier on top of per-emote sizes) so
-- // chat overlays can honour it — the main chat already applies this via chat settings.
-- let emoteScale = 100;
-- if (streamUserId) {
-- try {
-- const ch = db.getChannelByUserId(streamUserId);
-- const st = ch ? db.getChannelModerationSettings(ch.id) : null;
-- if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;
-- } catch { /* default 100 */ }
-- }
--
-- res.json({
-- defaults,
-- channel: channelEmotes,
-- global: globalCustom,
-- ffz: ffzEmotes,
-- bttv: bttvEmotes,
-- '7tv': sevenTvEmotes,
-- sources,
-- emote_scale: emoteScale,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to load emotes' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/index.js b/server/index.js
iindex f981d749..ccfe2c1d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -129,7 +129,6 @@ const { requireAuth } = require('./auth/auth');
const permissions = require('./auth/permissions');
const robotStreamerRoutes = require('./integrations/routes');
const themeRoutes = require('./themes/routes');
--const emoteRoutes = require('./emotes/routes');
const metaRoutes = require('./meta/routes');
const robotStreamerService = require('./integrations/robotstreamer-service');
const chatRelayService = require('./integrations/chat-relay-service');
@@@ -703,14 +702,12 @@ app.use('/api/onvif', onvifRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin', adminRoutes);
app.use('/api/mod', require('./admin/mod-routes'));
--app.use('/api/channels', require('./admin/channel-mod-routes'));
app.use('/api/robotstreamer', robotStreamerRoutes);
app.use('/api/restream', restreamRoutes);
// Stream thumbnails are public pictures other OpenVibe sites show (the network's front door lists who is live), so
// they may be embedded cross-site; everything else keeps helmet's same-origin resource policy.
app.use('/api/thumbnails', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }, thumbnailRoutes);
app.use('/api/themes', themeRoutes);
--app.use('/api/emotes', emoteRoutes);
app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));
app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
@@@ -1137,9 +1134,6 @@ async function start() {
dm.ensureTables();
// Back from CHAT_AUTHORITY=chat (rollback): chat writes OpenVibe.Chat never acknowledged land here.
if (!require('./chat/chat-authority').isRemote()) require('./chat/chat-remote').drainToLocal();
-- // The staged chat tables (C-04): with CHAT_AUTHORITY=chat, Live's changes to the ones it writes
-- // reach OpenVibe.Chat, and the dual read compares (server/chat/chat-tables.js). Nothing otherwise.
-- require('./chat/chat-tables').init();
// 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
@@@ -1366,7 +1360,6 @@ async function start() {
// AI viewers activity log: keep a week.
setInterval(() => { try { const n = db.pruneAiViewerLog(7); if (n) console.log(`[AI-Viewers] pruned ${n} log row(s)`); } catch { /* */ } }, 6 * 3600 * 1000);
try { require('./ai/streamer-overview-job').start(); } catch (e) { console.warn('[AI] streamer-overview job not started:', e.message); }
-- try { require('./ai/chat-ai').start(); } catch (e) { console.warn('[AI] chat-ai job not started:', e.message); }
try { require('./ai/slogan-job').start(); } catch (e) { console.warn('[AI] slogan job not started:', e.message); }
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 5cdd60cd..ecd2dafe 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -9,48 +9,12 @@ const { guard } = require('../net/service-guard');
// Service-to-service only, loopback only (nothing that came through nginx): each route checks the one capability it
// performs on a Network service token (server/net/service-guard.js); nothing else gets in (X-Internal-Key: plan T2).
--const chatTablesGuard = guard('live.chat_mirror.write'); // OpenVibe.Chat's staged-table handoff (T3 deletes)
// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
function notifyChat(userId) {
try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ }
}
--// ── The staged chat tables (roadmap C-04, server/chat/chat-tables.js) ─────────────
--// GET /internal/chat-tables who writes each table (here and in OpenVibe.Chat), the relay to
--// Chat, the dual-read counts
--// POST /internal/chat-tables/:table { dual_read?: bool, reset_counters?: bool, authority?: 'live'|'chat',
--// by?: 'who', force?: true (back to 'live' with Chat down) } — the
--// flag, the counters, then the handoff
--router.get('/chat-tables', chatTablesGuard, async (req, res) => {
-- try { res.json(await require('../chat/chat-tables').status()); } catch (err) { res.status(500).json({ error: err.message }); }
--});
--// POST /internal/chat-tables/relay { paused: bool } — hold the relay to Chat (the capture goes on) for an import
--router.post('/chat-tables/relay', chatTablesGuard, (req, res) => {
-- try {
-- const sync = require('../chat/chat-tables-sync');
-- if (req.body && req.body.paused !== undefined) sync.setPaused(req.body.paused === true || req.body.paused === 'true');
-- res.json({ ok: true, relay: sync.relayStats() });
-- } catch (err) { res.status(500).json({ error: err.message }); }
--});
--router.post('/chat-tables/:table', chatTablesGuard, async (req, res) => {
-- const chatTables = require('../chat/chat-tables');
-- const table = String(req.params.table);
-- const b = req.body || {};
-- try {
-- if (!chatTables.TABLES[table]) return res.status(404).json({ error: `${table} is not a staged table` });
-- if (b.dual_read !== undefined) chatTables.setDualRead(table, b.dual_read === true || b.dual_read === 'true' || b.dual_read === 1);
-- if (b.reset_counters) require('../chat/chat-tables-sync').resetDualReadStats(table);
-- let handoff = null;
-- if (b.authority !== undefined) handoff = await chatTables.setAuthority(table, String(b.authority), { by: String(b.by || 'operator').slice(0, 80), force: b.force === true });
-- const st = await chatTables.status();
-- res.json({ ok: true, handoff, table: st.tables[table], chat_error: st.chat_error });
-- } catch (err) {
-- const st = await chatTables.status({ askChat: false }).catch(() => null);
-- res.status(err.status || 500).json({ ok: false, error: err.message, table: st ? st.tables[table] : null });
-- }
--});
--
// Footer site copy is written by OpenVibe.AI for the Network directly (network.site_copy); the
// /internal/ai/site-copy fallback that used to live here was retired on 2026-09-23.
ddiff --git a/test/ai-templates-jobs.test.js b/test/ai-templates-jobs.test.js
iindex 644609c6..7ad9c7f9 100644
---- a/test/ai-templates-jobs.test.js
++++ b/test/ai-templates-jobs.test.js
@@@ -86,19 +86,8 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
const pool = JSON.parse(db.getState('home_hero_slogans'));
assert.ok(pool.audiences.includes('crouton fans 0') && !pool.audiences.some((a) => /free/.test(a)), 'the no-free rule still applies');
assert.ok(pool.audiences.includes('coders'), '"live streaming for" is stripped');
-- // Chat messages go to live.chat.global / live.chat.profile as data, in the shape AI formats.
-- const now = Date.parse('2026-09-28T01:00:00Z');
-- const data = require('../server/ai/chat-ai')._msgData([
-- { username: 'goosely', is_global: 1, message: ' croutons ', timestamp: '2026-09-28 00:48:00' },
-- { user_id: 5, channel_username: 'ann', message_type: 'emote', message: 'waves', timestamp: '2026-09-28 00:57:00' },
-- { message: 'hi', stream_id: 3 },
-- ], { includeChannel: true, now });
-- assert.deepStrictEqual(data, [
-- { mins_ago: 12, where: 'global', author: 'goosely', kind: null, text: 'croutons' },
-- { mins_ago: 3, where: '#ann', author: 'user#5', kind: 'emote', text: 'waves' },
-- { mins_ago: null, where: 'stream', author: 'anon', kind: null, text: 'hi' },
-- ]);
-- assert.strictEqual(require('../server/ai/chat-ai')._msgData([{ username: 'x', is_global: 1, message: 'y' }]).pop().where, null, 'no channel tag in a profile');
++ // The chat-AI job and its live.chat.global / live.chat.profile data shaping moved to
++ // OpenVibe.Chat with the six chat tables (roadmap T3).
} finally {
network.close(); ai.close();
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/channel-moderation-routes.test.js b/test/channel-moderation-routes.test.js
ddeleted file mode 100644
iindex cd118896..00000000
---- a/test/channel-moderation-routes.test.js
++++ /dev/null
@@@ -1,165 +0,0 @@
--/**
-- * The dashboard's channel moderation routes (server/admin/channel-mod-routes.js), for the chat
-- * parity gaps of roadmap WS-I task 6:
-- * - the dashboard's slow mode is saved in slow_mode_seconds (it sent slowmode_seconds, which no
-- * writer knows, so it was never saved) and sub-only mode is a saved setting like the others;
-- * - deleting one message reaches every surface that shows it: the streamer's whole channel room
-- * (every live slot, the offline room, a channel popout) and the global feed.
-- *
-- * The real router on a temp database; only sign-in is stubbed (x-test-user) and the chat server's
-- * broadcasts are recorded.
-- *
-- * node test/channel-moderation-routes.test.js
-- */
--'use strict';
--const assert = require('assert');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--
--process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);
--process.env.NODE_ENV = 'test';
--// Live reads channel_moderators through OpenVibe.Chat (roadmap T3), so the routes need a stub Chat
--// (its internal read API) and a stub Network token endpoint. Fixed ports, env set before any require.
--const CHAT_PORT = 45000 + (process.pid % 400);
--const NET_PORT = CHAT_PORT + 1;
--process.env.OV_OAUTH_CLIENT_SECRET = 'chanmod-secret';
--process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${CHAT_PORT}`;
--process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${NET_PORT}`;
--const quiet = console.log;
--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
--
--const db = require('../server/db/database');
--db.initDb();
--const raw = db.getDb();
--
--const netStub = 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: 'stub', expires_in: 300 }));
-- res.statusCode = 404; res.end('{}');
-- });
--});
--const chatStub = http.createServer((req, res) => {
-- res.setHeader('Content-Type', 'application/json');
-- const mm = String(req.url).match(/^\/internal\/moderation\/channels\/(\d+)$/);
-- if (mm) {
-- const cid = Number(mm[1]);
-- return res.end(JSON.stringify({
-- ok: true,
-- settings: db.getChannelModerationSettings(cid) || {},
-- moderator_ids: raw.prepare('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id').all(cid).map((r) => r.user_id),
-- }));
-- }
-- res.statusCode = 404; res.end('{}');
--});
--netStub.listen(NET_PORT); chatStub.listen(CHAT_PORT);
--
--const auth = require('../server/auth/auth');
--auth.requireAuth = (req, res, next) => {
-- const u = db.getUserById(Number(req.headers['x-test-user'] || 0));
-- if (!u) return res.status(401).json({ error: 'Authentication required' });
-- req.user = u; req.authSource = 'network';
-- next();
--};
--
--// What the routes push to browsers.
--const chatServer = require('../server/chat/chat-server');
--const sent = [];
--for (const fn of ['broadcastToStream', 'broadcastToChannelRoom', 'forwardToGlobal', 'forwardToGlobalByChannel', 'broadcastGlobal']) {
-- chatServer[fn] = (...args) => { sent.push([fn, ...args.slice(0, -1), args[args.length - 1].type]); };
--}
--
--const addUser = (id, username, role) => raw.prepare("INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)").run(id, username, username, `${username}@x`, role);
--addUser(1, 'streamer', 'streamer');
--addUser(2, 'moddy', 'user');
--addUser(3, 'viewer', 'user');
--db.ensureChannel(1);
--const channel = db.getChannelByUserId(1);
--raw.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, 2, 1);
--const streamId = Number(db.createStream({ user_id: 1, channel_id: channel.id, title: 'Live', protocol: 'webrtc' }).lastInsertRowid);
--const liveLine = Number(db.saveChatMessage({ stream_id: streamId, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while live' }).lastInsertRowid);
--const offlineLine = Number(db.saveChatMessage({ stream_id: null, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while offline' }).lastInsertRowid);
--
--const express = require('express');
--const app = express();
--app.use(express.json());
--app.use('/api/channels', require('../server/admin/channel-mod-routes'));
--const server = http.createServer(app).listen(0);
--
--function call(method, p, user, body) {
-- return new Promise((resolve, reject) => {
-- const data = body ? JSON.stringify(body) : null;
-- const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) } }, (res) => {
-- let text = '';
-- res.on('data', (c) => { text += c; });
-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
-- });
-- req.on('error', reject);
-- if (data) req.write(data);
-- req.end();
-- });
--}
--
--let failures = 0;
--async function check(name, fn) {
-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
--}
--
--(async () => {
-- const listening = (s) => (s.listening ? Promise.resolve() : new Promise((r) => s.once('listening', r)));
-- await listening(server);
-- await Promise.all([listening(netStub), listening(chatStub)]);
-- const settings = () => db.getChannelModerationSettings(channel.id);
--
-- await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {
-- let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slow_mode_seconds: 7 });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual(settings().slow_mode_seconds, 7);
-- assert.strictEqual(r.json.settings.slow_mode_seconds, 7);
-- r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slowmode_seconds: 4 });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual(settings().slow_mode_seconds, 4, 'a cached dashboard that still sends slowmode_seconds');
-- });
--
-- await check('sub-only mode is a saved channel setting: the owner and a channel mod may set it, a viewer may not', async () => {
-- assert.strictEqual(settings().sub_only, 0, 'off by default');
-- assert.strictEqual((await call('PUT', `/api/channels/${channel.id}/moderation`, 3, { sub_only: true })).status, 403);
-- assert.strictEqual(settings().sub_only, 0);
-- let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 2, { sub_only: true });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual([settings().sub_only, settings().slow_mode_seconds], [1, 4], 'set; the rest kept');
-- r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { sub_only: false, followers_only: true });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual([settings().sub_only, settings().followers_only], [0, 1]);
-- const other = db.ensureChannel(3) || db.getChannelByUserId(3);
-- r = await call('PUT', `/api/channels/${other.id}/moderation`, 3, { sub_only: 1 });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual(db.getChannelModerationSettings(other.id).sub_only, 1, 'a channel with no settings row yet');
-- });
--
-- await check('deleting a line reaches the whole channel room and the global feed', async () => {
-- sent.length = 0;
-- let r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${liveLine}/delete`, 2);
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(sent, [
-- ['broadcastToChannelRoom', 1, streamId, 'delete-messages'],
-- ['forwardToGlobal', streamId, 'delete-messages'],
-- ]);
-- sent.length = 0;
-- r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${offlineLine}/delete`, 2);
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(sent, [
-- ['broadcastToChannelRoom', 1, null, 'delete-messages'],
-- ['forwardToGlobalByChannel', 1, 'delete-messages'],
-- ], 'an offline line: the channel room (popout, channel page) and the global feed');
-- assert.strictEqual(db.getChatMessageById(offlineLine).is_deleted, 1);
-- });
--
-- server.close();
-- try { db.getDb().close(); } catch { /* */ }
-- for (const f of ['', '-wal', '-shm']) { try { require('fs').rmSync(process.env.DB_PATH + f, { force: true }); } catch { /* */ } }
-- quiet(failures ? `channel moderation routes: ${failures} failed` : 'channel moderation routes: all checks passed');
-- process.exit(failures ? 1 : 0);
--})();
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex beae9051..6ba0adec 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -73,17 +73,23 @@ const chat = http.createServer((req, res) => {
if (mm) {
const d2 = require('../server/db/database');
const channelId = Number(mm[1]);
-- if (mm[2]) { const ch = d2.getChannelById(channelId); return res.end(JSON.stringify({ ok: true, count: ch ? d2.countChannelEmotes(ch.user_id) : 0 })); }
++ // Chat answers these from its own tables (it owns the six staged tables, roadmap T3); the
++ // test reads them directly here to emulate Chat's copy.
++ if (mm[2]) {
++ const ch = d2.getChannelById(channelId);
++ const n = ch ? (d2.get('SELECT COUNT(*) AS n FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)', [ch.user_id, ch.user_id])?.n || 0) : 0;
++ return res.end(JSON.stringify({ ok: true, count: n }));
++ }
return res.end(JSON.stringify({
ok: true,
-- settings: d2.getChannelModerationSettings(channelId) || {},
++ settings: d2.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]) || {},
moderator_ids: d2.all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY id', [channelId]).map((r) => r.user_id),
}));
}
const um = String(req.url).match(/^\/internal\/moderation\/users\/(\d+)\/channels$/);
if (um) {
const d2 = require('../server/db/database');
-- const rows = d2.getChannelsByModerator(Number(um[1])) || [];
++ const rows = d2.all('SELECT cm.channel_id AS id, c.title, c.user_id FROM channel_moderators cm JOIN channels c ON cm.channel_id = c.id WHERE cm.user_id = ?', [Number(um[1])]) || [];
return res.end(JSON.stringify({ ok: true, channels: rows.map((c) => ({ channel_id: c.id, title: c.title, owner_user_id: c.user_id })) }));
}
res.statusCode = 404; res.end('{}');
@@@ -300,11 +306,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.deepStrictEqual(db.deleteUserChatMessages(mod, {}), [900002]);
await chatServer.flush();
assert.deepStrictEqual(bridgeCalls.at(-1).ops[0].args.slice(0, 2), ['deleteUserChatMessages', mod]);
-- // Live's own writes to data Chat caches (dashboard mods, IP approvals) tell Chat to reload it.
-- db.addChannelModerator(channel.id, viewer, streamer);
-- await chatServer.flush();
-- assert.deepStrictEqual(bridgeCalls.at(-1).ops.map((o) => [o.op, ...o.args]), [['invalidate', 'channel', channel.id]]);
-- assert.ok(d.prepare('SELECT 1 FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(channel.id, viewer), 'the write itself still happens in Live');
++ // Live's own writes to data Chat caches (IP approvals, bans) tell Chat to reload it; the
++ // channel-moderation writes went to Chat with the six staged tables (roadmap T3).
// The /api/mod global delete loops over chatServer.clients: one pseudo-socket reaches everyone.
for (const [ws] of chatServer.clients) { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'delete-messages', ids: [1] })); }
await chatServer.flush();
ddiff --git a/test/chat-insight-client.test.js b/test/chat-insight-client.test.js
nnew file mode 100644
iindex 00000000..d0fb1b7c
---- /dev/null
++++ b/test/chat-insight-client.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++// server/chat/insight-client.js (roadmap T3): the chat-AI summaries are OpenVibe.Chat's, and Live's own
++// features fold them in (the persona's prompt context, the hero slogans, the easter egg, the viewer
++// clone, the channel AI timeline and the user card's chat side) by reading Chat's public
++// /api/chat/ai/* answers. This checks the client they share:
++// - it reads { insight } from the right path for global / user / anon / relay chatters;
++// - an answer is cached (one fetch for repeated reads, concurrent reads share one fetch);
++// - peek*() answers null on a cold cache, warms it, and answers the insight next time;
++// - a failure answers null and never throws; bad ids never reach Chat.
++// Against a stub Chat.
++
++const assert = require('assert');
++const http = require('http');
++
++process.env.NODE_ENV = 'test';
++console.warn = () => {};
++
++const GLOBAL = { overview: 'Chat is excited about the raid.', memory: 'Raid at 20:00.', timeline: [] };
++const USER = { overview_24h: 'Asks about builds.', memory: 'Plays support.' };
++let failing = false;
++const calls = [];
++const chat = http.createServer((req, res) => {
++ calls.push(req.url);
++ res.setHeader('Content-Type', 'application/json');
++ if (failing) { res.statusCode = 503; return res.end('{"error":"down"}'); }
++ if (req.url === '/api/chat/ai/global') return res.end(JSON.stringify({ insight: GLOBAL }));
++ if (req.url === '/api/chat/ai/user/7') return res.end(JSON.stringify({ insight: USER, streamer: null, user: { id: 7 } }));
++ if (req.url === '/api/chat/ai/anon/anon42') return res.end(JSON.stringify({ insight: { overview_24h: 'lurker' } }));
++ if (req.url === '/api/chat/ai/relay/twitch/Some%20One') return res.end(JSON.stringify({ insight: { overview_24h: 'from twitch' } }));
++ if (req.url === '/api/chat/ai/user/8') return res.end(JSON.stringify({ insight: null }));
++ res.statusCode = 404; res.end('{}');
++});
++
++(async () => {
++ await new Promise((r) => chat.listen(0, '127.0.0.1', r));
++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${chat.address().port}`;
++ const client = require('../server/chat/insight-client');
++
++ assert.deepStrictEqual(await client.getGlobal(), GLOBAL);
++ assert.deepStrictEqual(await client.getUser(7), USER);
++ assert.deepStrictEqual(await client.getAnon('anon42'), { overview_24h: 'lurker' });
++ assert.deepStrictEqual(await client.getRelay('twitch', 'Some One'), { overview_24h: 'from twitch' });
++ assert.strictEqual(await client.getUser(8), null, 'no insight yet answers null');
++
++ // Cached: repeated and concurrent reads cost one fetch.
++ const before = calls.length;
++ await Promise.all([client.getGlobal(), client.getGlobal(), client.getUser(7)]);
++ assert.strictEqual(calls.length, before, 'cached answers are not fetched again');
++ client._reset();
++ await Promise.all([client.getGlobal(), client.getGlobal(), client.getGlobal()]);
++ assert.strictEqual(calls.length, before + 1, 'concurrent reads share one fetch');
++
++ // Peeks: cold → null and a background fetch; warm → the insight.
++ client._reset();
++ assert.strictEqual(client.peekUser(7), null, 'a cold peek answers null');
++ await new Promise((r) => setTimeout(r, 100));
++ assert.deepStrictEqual(client.peekUser(7), USER, 'the peek warmed the cache');
++ assert.strictEqual(client.peekGlobal(), null);
++ await new Promise((r) => setTimeout(r, 100));
++ assert.strictEqual(client.peekGlobal().overview, GLOBAL.overview);
++
++ // Bad ids never reach Chat.
++ const n = calls.length;
++ assert.strictEqual(await client.getUser('x'), null);
++ assert.strictEqual(await client.getAnon('../global'), null);
++ assert.strictEqual(client.peekAnon('nope'), null);
++ assert.strictEqual(calls.length, n, 'no request for an invalid id');
++
++ // Chat down: null, never a throw.
++ client._reset();
++ failing = true;
++ assert.strictEqual(await client.getGlobal(), null);
++ assert.strictEqual(await client.getRelay('kick', 'someone'), null);
++ assert.strictEqual(client.peekUser(7), null);
++
++ chat.close();
++ console.log('chat insight client: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/chat-staged-tables.test.js b/test/chat-staged-tables.test.js
nnew file mode 100644
iindex 00000000..c12c009c
---- /dev/null
++++ b/test/chat-staged-tables.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++
++// The six staged chat tables (channel_moderators, channel_moderation_settings, emotes, user_tags,
++// chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's (roadmap T3), and so are the relay
++// bookkeeping tables chat_staged_outbox and chat_dual_read_stats, which belonged to the staged-table
++// machinery this release deleted (server/chat/chat-tables*.js). Like the frozen vods/clips tables
++// (test/frozen-tables.test.js), no server/ or scripts/ code may WRITE them and none may READ them.
++//
++// The tables stay in the schema one more release for N-1 (ADR-016): the previous release's SQL still
++// prepares against them (test/n-1.test.js) and its migrations expect them, so this is a code guard,
++// not a schema one — CREATE/ALTER/PRAGMA/DROP lines are not counted.
++//
++// The two reads that remain are allow-listed, each with a reason:
++// - the home hero's "emotes created" figure and its over-time series read Live's copy of `emotes`
++// while the table is still here; the number is kept for the mixed-version window (an N-1 client
++// reads stats.emotes) and goes with the table.
++// - the one-time rebuild of `emotes` on a pre-existing database (per-channel code uniqueness) is
++// schema upkeep, not a use of the data.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const STAGED = [
++ 'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags',
++ 'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats',
++];
++const T = STAGED.join('|');
++
++const WRITE = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');
++const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
++const READ_REGISTRY = new RegExp(`\\btable\\s*:\\s*[\`"'](${T})[\`"']`, 'g');
++
++// { file, snippet, reason }: each must be a real, current read (a stale entry fails the test).
++const ALLOWED_READS = [
++ { file: path.join('server', 'db', 'database.js'), snippet: "table: 'emotes'", reason: 'home hero "emotes created" series; the table stays for the N-1 client that reads stats.emotes' },
++ { file: path.join('server', 'db', 'database.js'), snippet: 'SELECT id, user_id, code, url, animated, width, height, is_global, is_approved, created_at, channel_owner_id, size FROM emotes', reason: 'the one-time pre-existing-database emotes rebuild (per-channel code uniqueness) is schema upkeep' },
++];
++
++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 staged-table read in one file's text → [{ line, text }]. */
++function findReads(src) {
++ const lines = src.split('\n');
++ const hits = [];
++ for (const re of [READ_SQL, READ_REGISTRY]) {
++ re.lastIndex = 0;
++ let m;
++ while ((m = re.exec(src))) {
++ const at = m.index + m[0].length - 1;
++ const lineNo = src.slice(0, at).split('\n').length;
++ const text = lines[lineNo - 1];
++ const startLine = src.slice(0, m.index).split('\n').length;
++ if (isCommentLine(lines[startLine - 1]) || isCommentLine(text)) continue;
++ hits.push({ line: lineNo, text: text.trim() });
++ }
++ }
++ return hits;
++}
++
++// The scanner itself: it has to catch the shapes the deleted machinery used, and leave schema
++// upkeep alone.
++for (const [src, n] of [
++ ["db.get('SELECT * FROM chat_ai_summaries WHERE scope = ?', [scope])", 1],
++ ["db.all(`SELECT cm.id FROM chat_messages cm LEFT JOIN channel_moderators cm2 ON cm2.channel_id = ?`)", 1],
++ ["const S = { emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' } };", 1],
++ ["db.run('INSERT INTO emote_media (id) VALUES (1)')", 0],
++ ["db.run('INSERT INTO emotes_new (id, code) VALUES (1, \\'x\\')')", 0],
++ ["db.run('INSERT INTO emotes_new (id, code) SELECT id, code FROM emotes')", 1],
++ ["db.run('UPDATE emotes SET code = ? WHERE id = ?')", 0],
++ ["database.exec('CREATE TABLE IF NOT EXISTS chat_staged_outbox (seq INTEGER)');", 0],
++ ["database.exec('DROP TABLE emotes');", 0],
++ [' // used to read FROM user_tags here', 0],
++ ["db.all('SELECT * FROM stream_timeline_events WHERE stream_id = ?')", 0],
++]) assert.strictEqual(findReads(src).length, n, `scanner on: ${src}`);
++
++const writes = [];
++const reads = [];
++const usedAllowances = new Set();
++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
++ const rel = path.relative(ROOT, file);
++ const src = fs.readFileSync(file, 'utf8');
++ src.split('\n').forEach((line, i) => {
++ if (WRITE.test(line) && !isCommentLine(line)) writes.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++ for (const hit of findReads(src)) {
++ const ok = ALLOWED_READS.find((a) => a.file === rel && hit.text.includes(a.snippet));
++ if (ok) { usedAllowances.add(ok); continue; }
++ reads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
++ }
++}
++assert.deepStrictEqual(writes, [], `writes to Chat's staged tables (OpenVibe.Chat owns them; write through Chat's API instead):\n${writes.join('\n')}`);
++assert.deepStrictEqual(reads, [], `reads of Chat's staged tables (read them through server/chat/moderation-client.js, or Chat's own API):\n${reads.join('\n')}`);
++for (const a of ALLOWED_READS) {
++ assert.ok(a.reason && a.reason.length > 20, `allowed read without a reason: ${a.file}`);
++ assert.ok(usedAllowances.has(a), `stale allow-list entry (the read is gone, remove it): ${a.file}: ${a.snippet}`);
++}
++console.log(`chat staged tables: no writes, no unlisted reads — ${STAGED.length} tables, ${ALLOWED_READS.length} allowed read(s)`);
ddiff --git a/test/chat-tables-writers.test.js b/test/chat-tables-writers.test.js
ddeleted file mode 100644
iindex 0339a21c..00000000
---- a/test/chat-tables-writers.test.js
++++ /dev/null
@@@ -1,63 +0,0 @@
--'use strict';
--
--// Every write to a staged chat table (channel_moderators, channel_moderation_settings, emotes,
--// user_tags, chat_ai_summaries, chat_timeline_events — roadmap C-04) goes through
--// server/chat/chat-tables.js write(), which sends it to OpenVibe.Chat once Chat writes the table.
--// A writer that called database.js directly, or wrote SQL of its own, would keep writing Live's copy
--// after the handoff — a second writer next to Chat. So:
--// - no server/ or scripts/ code calls one of database.js's staged-table writers directly (chat-tables
--// calls them as db[op] while Live writes the table);
--// - SQL that writes a staged table appears only in database.js, inside one of those writers;
--// - chat-tables.OPS lists exactly database.js's writers.
--// Schema upkeep (CREATE/ALTER, the one-time emotes_new rebuild) is not a write. Comment lines never count.
--
--const assert = require('assert');
--const fs = require('fs');
--const path = require('path');
--
--const ROOT = path.join(__dirname, '..');
--const { OPS, TABLES } = require('../server/chat/chat-tables');
--const T = Object.keys(TABLES).join('|');
--const WRITE_SQL = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(${T})\\b`, 'i');
--const DIRECT_CALL = new RegExp(`\\bdb\\.(${Object.keys(OPS).join('|')})\\s*\\(`);
--const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*)/.test(line);
--
--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')) out.push(p);
-- }
-- return out;
--}
--
--const problems = [];
--const writersInDb = new Set();
--for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
-- const rel = path.relative(ROOT, file);
-- const lines = fs.readFileSync(file, 'utf8').split('\n');
-- let fn = null;
-- lines.forEach((line, i) => {
-- const def = /^(?:async\s+)?function\s+(\w+)/.exec(line);
-- if (def) fn = def[1];
-- if (isCommentLine(line)) return;
-- if (DIRECT_CALL.test(line)) problems.push(`${rel}:${i + 1} calls database.js directly — use require('../chat/chat-tables').write(): ${line.trim().slice(0, 120)}`);
-- if (WRITE_SQL.test(line)) {
-- if (rel === path.join('server', 'db', 'database.js') && fn && OPS[fn]) { writersInDb.add(fn); return; }
-- problems.push(`${rel}:${i + 1} writes a staged chat table outside database.js's writers (${fn || 'top level'}): ${line.trim().slice(0, 120)}`);
-- }
-- });
--}
--
--try {
-- assert.deepStrictEqual(problems, [], `staged-table writes that bypass chat-tables.write():\n ${problems.join('\n ')}`);
-- // Every writer chat-tables knows exists in database.js and writes SQL there (setChannelAlertSound and
-- // upsertChannelModerationSettings write the same table; each has its own SQL).
-- const db = require('../server/db/database');
-- for (const op of Object.keys(OPS)) assert.strictEqual(typeof db[op], 'function', `database.js exports ${op}`);
-- assert.deepStrictEqual([...writersInDb].sort(), Object.keys(OPS).sort(), 'chat-tables.OPS lists exactly the functions that write the staged tables');
-- console.log(`chat-tables writers: ${Object.keys(OPS).length} writers, all through chat-tables.write()`);
--} catch (err) {
-- console.error(err.message);
-- process.exit(1);
--}
ddiff --git a/test/chat-tables.test.js b/test/chat-tables.test.js
ddeleted file mode 100644
iindex 6ac9e753..00000000
---- a/test/chat-tables.test.js
++++ /dev/null
@@@ -1,9 +0,0 @@
--'use strict';
--// roadmap T3: the six chat tables (channel_moderators, channel_moderation_settings, emotes,
--// user_tags, chat_ai_summaries, chat_timeline_events) are OpenVibe.Chat's, and this release deletes
--// the staged machinery this file exercised (chat-tables.js, chat-tables-sync.js, the
--// /internal/chat-tables* routes, the staged branch of the read mirror, the app_state keys and the
--// chat_staged_outbox/chat_dual_read_stats tables). There is nothing left here to check; the file
--// only reports that it is retired, and is listed with the machinery for physical removal under
--// "For Opus" (file deletion is not available in this run).
--console.log('T3 staged chat tables: skipped (roadmap T3: Chat owns the six tables)');
ddiff --git a/test/cosmetics-internal.test.js b/test/cosmetics-internal.test.js
iindex 2bb270ff..3609cd97 100644
---- a/test/cosmetics-internal.test.js
++++ b/test/cosmetics-internal.test.js
@@@ -70,10 +70,6 @@ const token = (cap, { aud = 'openvibe.live', sub = 'svc:network', env } = {}) =>
// ── the other internal routes: each has its own capability ───────────────────
assert.strictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.avatar.write'])))).status, 403);
assert.notStrictEqual((await post('/internal/url-registry/refresh', bearer(token(['live.url_registry.refresh'])))).status, 403, 'live.url_registry.refresh opens the refresh');
-- const tables = (headers) => fetch(`${origin}/internal/chat-tables`, { headers });
-- assert.strictEqual((await tables(bearer(token(['live.avatar.write'], { sub: 'svc:chat' })))).status, 403);
-- assert.notStrictEqual((await tables(bearer(token(['live.chat_mirror.write'], { sub: 'svc:chat' })))).status, 403, 'Chat opens the staged-table handoff with live.chat_mirror.write');
-- assert.strictEqual((await tables({})).status, 401);
// No server file reads or sends the retired key.
const offenders = [];
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 2c778957..f9bc208e 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -132,7 +132,6 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
db.ensureChannel(star); db.ensureChannel(fan);
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 emotes (user_id, code, url, is_global) VALUES (?, 'drillWave', '/api/emotes/file/wave.png', 1)", [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)
@@@ -229,13 +228,9 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
const themes = await request('GET', '/api/themes');
assert.strictEqual(themes.status, 200);
assert.ok(themes.json.themes.length > 0);
-- const emotes = await request('GET', '/api/emotes/global');
-- assert.deepStrictEqual(emotes.json.emotes.map((e) => e.code), ['drillWave']);
const live = await request('GET', '/api/streams/channel/drillstar/live');
assert.strictEqual(live.status, 200);
assert.strictEqual(live.json.streams.length, 1);
-- const chEmotes = await request('GET', '/api/emotes/channel/1');
-- assert.strictEqual(chEmotes.status, 200);
assert.deepStrictEqual(drill.blocked, [], 'none of these asked another service');
assert.deepStrictEqual(counts(), countsBefore, 'and none of them wrote');
});
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex 78bbfe98..37f9b403 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -105,6 +105,9 @@ async function check(name, fn) {
const n1Client = async (url) => {
const probe = await h.notFoundProbe(url);
for (const rec of client.calls) {
++ // A path Chat serves now (nginx routes /api/emotes and /api/chat/ there) is not
++ // this release's to answer; the fixture was recorded before that routing existed.
++ if (!svc.keep(rec.path.split('?')[0])) continue;
const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });
const problems = h.callProblems(rec, got, probe);
if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 239c0d87..62428261 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -20,8 +20,11 @@ const PRELOAD = path.join(__dirname, 'preload.js');
const ISSUER = 'https://openvibe.network';
// Served by OpenVibe.Chat on openvibe.live (Chat's deploy/nginx/openvibe.live-chat.locations.conf):
--// Live's chat widget and messenger are Chat's N-1 clients, checked there.
--const CHAT_PATHS = /^\/(api\/(chat|dm|tts)\/|api\/sounds(\/|$)|ws\/)/;
++// Live's chat widget and messenger are Chat's N-1 clients, checked there. /api/emotes is Chat's too
++// (plan T3; nginx sends it there), and the channel-moderation router Live used to answer at
++// /api/channels/* moved to Chat's /api/chat/channels/* — so an N-1 tab's calls to those are
++// Chat's to answer, and this harness neither records nor replays them against Live.
++const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|ws\/)/;
function freePort() {
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 3716e928..29ddeca3 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -11,7 +11,7 @@
* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
* answer would be the new key), the slot's vibe-coding settings;
* - restream destinations (each holds a platform stream key): edit, delete, start, stop;
-- * - API tokens, channel emotes, ONVIF cameras and their presets.
++ * - API tokens, ONVIF cameras and their presets.
*
* Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and
* return nothing of it (no key); each area also has a positive control, so a route that simply
@@@ -65,7 +65,6 @@ const streamA = Number(db.createStream({ user_id: ALICE, channel_id: chanA.id, m
const DEST_KEY = 'restream-idor-key-cdcd';
const destA = Number(db.createRestreamDestination(ALICE, { platform: 'custom', name: 'Alice mirror', server_url: 'rtmp://ingest.example.test/live', stream_key: DEST_KEY, managed_stream_id: slotA }).lastInsertRowid);
const tokenA = db.createApiToken(ALICE, 'Alice bot', ['chat', 'read']).id;
--const emoteA = Number(raw.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, 'aliceWave', '/api/emotes/file/none.png', ?)").run(ALICE, ALICE).lastInsertRowid);
const camA = Number(raw.prepare("INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, 'Alice cam', 'http://camera.example.test', 'admin', 'x')").run(ALICE, streamA).lastInsertRowid);
const presetA = Number(raw.prepare("INSERT INTO camera_presets (camera_id, name, pan, tilt, zoom) VALUES (?, 'Desk', 0.5, 0.5, 0.5)").run(camA).lastInsertRowid);
// Bob has his own slot and stream, so "my slot" routes have something of his to compare against.
@@@ -106,7 +105,6 @@ app.use('/api/restream', require('../server/streaming/restream-routes'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/clips', require('../server/media-proxy/clips'));
app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
--app.use('/api/emotes', require('../server/emotes/routes'));
app.use('/api/onvif', require('../server/controls/onvif-routes'));
app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));
const server = http.createServer(app).listen(0, '127.0.0.1');
@@@ -243,12 +241,6 @@ async function check(name, fn) {
refused(await call('DELETE', `/api/auth/tokens/${tokenA}`, BOB), 'DELETE token');
assert.strictEqual(row('api_tokens', tokenA).is_active, 1);
});
-- await check('emotes: Bob cannot rename or delete Alice\'s channel emote', async () => {
-- const before = row('emotes', emoteA);
-- refused(await call('PATCH', `/api/emotes/${emoteA}`, BOB, { code: 'bobPwn' }), 'PATCH emote');
-- refused(await call('DELETE', `/api/emotes/${emoteA}`, BOB), 'DELETE emote');
-- assert.deepStrictEqual(row('emotes', emoteA), before);
-- });
await check('ONVIF cameras: Bob cannot read, edit or delete Alice\'s camera or its presets', async () => {
const before = [row('camera_profiles', camA), row('camera_presets', presetA)];
refused(await call('GET', `/api/onvif/cameras/${camA}`, BOB), 'GET camera');
@@@ -259,14 +251,13 @@ async function check(name, fn) {
refused(await call('DELETE', `/api/onvif/cameras/${camA}`, BOB), 'DELETE camera');
assert.deepStrictEqual([row('camera_profiles', camA), row('camera_presets', presetA)], before);
});
-- await check('control: Alice can rename her emote, read her camera and revoke her token', async () => {
-- assert.strictEqual((await call('PATCH', `/api/emotes/${emoteA}`, ALICE, { code: 'aliceWave2' })).status, 200);
++ await check('control: Alice can read her camera and revoke her token', async () => {
assert.strictEqual((await call('GET', `/api/onvif/cameras/${camA}`, ALICE)).status, 200);
assert.strictEqual((await call('DELETE', `/api/auth/tokens/${tokenA}`, ALICE)).status, 200);
});
await check('anonymous callers get 401 on all of them', async () => {
for (const [m, p] of [['PUT', '/api/vods/100'], ['DELETE', '/api/clips/200'], ['PUT', `/api/streams/managed/${slotA}`], ['POST', `/api/streams/managed/${slotA}/regenerate-key`],
-- ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`], ['DELETE', `/api/emotes/${emoteA}`], ['DELETE', `/api/onvif/cameras/${camA}`]]) {
++ ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`], ['DELETE', `/api/onvif/cameras/${camA}`]]) {
const r = await call(m, p, null, {}).catch((e) => ({ status: 0, text: e.message }));
assert.strictEqual(r.status, 401, `${m} ${p}: ${r.status} ${r.text}`);
}
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 8524e3c9..aeb81947 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -347,8 +347,8 @@ async function check(name, fn) {
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/chat/chat-remote.js': 'OpenVibe.Chat (configured)',
-- 'server/chat/chat-tables.js': 'OpenVibe.Chat (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
++ 'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)',
'server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)',
'server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',
@@@ -367,7 +367,6 @@ async function check(name, fn) {
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
-- 'server/emotes/routes.js': 'FFZ / BTTV / 7TV (fixed hosts)',
'server/meta/routes.js': 'GitHub API (fixed host)',
'server/seo/seo.js': 'a local function named fetch (no request)',
'server/news/sources/newsapi-source.js': 'NewsAPI (fixed host)',