openvibers/openvibe.live Commits
Delete the chat read mirror's receiver: POST /internal/chat-effects/mirror, delivery.mirror() and every call site; Chat retired the sender in #25 (#32)
Changes
@@@ -90,7 +90,7 @@ The media subsystem lives in **OpenVibe.Media**:## Capabilities--Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.++Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.Live's own principal (`live`) holds grants to call:@@@ -10,7 +10,6 @@"capabilities": ["live.chat_context.read","live.chat_effects.write",-- "live.chat_mirror.write","live.lineage.resolve","live.tips_delivery.write","live.channel.read",@@@ -49,20 +49,20 @@ path are deleted.`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through-- [server/chat/chat-reads.js](../server/chat/chat-reads.js), in place of the read mirror-- (`POST /internal/chat-effects/mirror`) home stats, recaps, AI context, VOD chat replay and the-- `/api/mod` queues used to read. A Chat outage answers a cached value or Live's own table, never a 500.--- **What reads the mirror.** Nothing in chat mode any more: the two HOME_SERIES charts `messages`-- and `active` read Chat's site-daily series, the AI context's "first time chatting here" flag reads-- Chat's first-chat read, and the RobotStreamer `!sound` lookup reads Chat's sounds-by-command read —-- each through [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own-- table only while Chat is unreachable. The mirror (`POST /internal/chat-effects/mirror`) therefore-- has no chat-mode reader left and is removed in the remaining step: Chat stops its `LIVE_MIRROR`-- writer, then Live deletes the receiver (`live-context-routes.js` `effectsRouter`'s mirror route and-- its `live.chat_mirror.write` capability) and the mirror-kept chat tables. The readers that still-- answer from Live's tables are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —-- `dm_*` (no `/api/dm`), Live's chat server, the deploy notice, the admin chat log and the leftover-- dev/rollback fallbacks.++ [server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,++ recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value++ or Live's own table, never a 500.++- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`++ writer and the capture triggers), and this change removed Live's receiver — the++ `POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its++ `live.chat_mirror.write` capability and the `chat-delivery.js` mirror helper. Nothing in chat mode++ read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,++ the AI context's "first time chatting here" flag reads Chat's first-chat read, and the++ RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through++ [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own table only++ while Chat is unreachable. Live's own chat tables stay until a later change drops them; the readers++ that still answer from them are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —++ `dm_*` (no `/api/dm`), the deploy notice, the admin chat log and the leftover dev/rollback fallbacks.- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,`emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads themonly through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal@@@ -404,10 +404,8 @@ router.post('/delete-message', async (req, res) => {if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });}-- // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached;-- // the mirror keeps Live's own copy in step for its remaining readers.++ // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached.if (delivery.ingress()) {-- delivery.mirror('deleteChatMessage', parseInt(message_id), req.user.id);await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });}else db.deleteChatMessage(parseInt(message_id), req.user.id);@@@ -465,10 +463,6 @@ router.post('/delete-user-messages', async (req, res) => {// Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]: anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];-- const opts = { streamId: scopedStreamId, deletedBy: req.user.id };-- if (user_id) delivery.mirror('deleteUserChatMessages', parseInt(user_id), opts);-- else if (anon_id) delivery.mirror('deleteAnonChatMessages', anon_id, opts);-- else delivery.mirror('deleteRelayUserMessages', relay_username, opts);const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };const r = await delivery.moderate(action, { ...subject, ...scoped });ids = (r && Array.isArray(r.ids)) ? r.ids : [];@@@ -538,7 +532,6 @@ router.post('/relay-user/hide', async (req, res) => {// Unban / unhide — remove the ban for this exact relay identity only.if (action === 'unban' || action === 'unhide') {if (delivery.ingress()) {-- delivery.mirror('unhideRelayUserByIdentity', channel_id || null, platform, external_username);await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });}else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);@@@ -589,7 +582,7 @@ router.delete('/relay-user/:id', async (req, res) => {const allowed = permissions.isGlobalModOrAbove(req.user)|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });-- if (delivery.ingress()) { delivery.mirror('unhideRelayUser', row.id); await delivery.moderate('relay-unhide', { id: row.id }); }++ if (delivery.ingress()) { await delivery.moderate('relay-unhide', { id: row.id }); }else db.unhideRelayUser(row.id);invalidateRelayHidden(row.channel_id);@@@ -676,7 +669,6 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {// Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).if (delivery.ingress()) {-- delivery.mirror('approveAllFromIp', channelId, ip, req.user.id);await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });}else db.approveAllFromIp(channelId, ip, req.user.id);@@@ -732,7 +724,6 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {}if (delivery.ingress()) {-- delivery.mirror('denyAllFromIp', channelId, ip, req.user.id);await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });}else db.denyAllFromIp(channelId, ip, req.user.id);@@@ -768,7 +759,6 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {}if (delivery.ingress()) {-- delivery.mirror('reviewPendingIpMessage', parseInt(message_id), { status, reviewedBy: req.user.id, channelId });await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });} else db.reviewPendingIpMessage(parseInt(message_id), {status,@@@ -973,7 +963,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {try {const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);if (req.body?.reset) {-- if (delivery.ingress()) { delivery.mirror('deleteTtsVoiceOverride', identityKey); delivery.moderate('tts-voice-override', { identity_key: identityKey }); }++ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey }); }else db.deleteTtsVoiceOverride(identityKey);return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });}@@@ -991,7 +981,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };}const clamped = ttsEngine.clampVoiceParams(params);-- if (delivery.ingress()) { delivery.mirror('setTtsVoiceOverride', identityKey, clamped, req.user.id); delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }++ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);res.json({ isOverride: true, params: clamped });} catch (err) {@@@ -205,7 +205,8 @@ function personBlock(line, channelUserId, settings) {}// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat-- // read through a sync peek that falls back to Live's own mirror-kept table while Chat is down.++ // read through a sync peek; a cold cache or a Chat outage answers "not first" and never++ // Live's own frozen copy.const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');} catch { /* */ }@@@ -10,9 +10,8 @@* presence reads and the address/anon helpers Live's modules used on its old chat server — Live* runs no chat server in any mode;* - reads chat stats, queues and history from Chat's internal read API-- * (server/chat/chat-reads.js → chat-client.js), and still keeps its chat tables as a read-- * mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved-- * yet keep working and a rollback loses nothing;++ * (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was++ * retired on 2026-10-05 and Live keeps its chat tables only until a later change drops them;* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.* Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push* is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.@@@ -21,6 +21,7 @@* readTtsOverride(q) GET /internal/chat/moderation/tts-override chat.moderation.queue.read a voice override* readSounds(q) GET /internal/chat/sounds chat.sounds.read a channel's sound count, or its pending assets* readSoundByCommand(q) GET /internal/chat/sounds/by-command chat.sounds.read the approved sound a !command plays++ * readDmBlockState(q) GET /internal/chat/dm/block-state chat.messages.read has either user blocked the other (either direction)** Auth: Live's Network service principal (client_credentials, audience openvibe.chat, server/net/network-principal.js).* Every POST carries one idempotency `key` made when the operation is created and reused on every retry, so Chat@@@ -175,6 +176,7 @@ const readRelayUsers = (params) => read('relay-users', `/internal/chat/moderatioconst readRelayUser = (id) => read('relay-user', `/internal/chat/moderation/relay-users/${Number(id)}`);const readTtsOverride = (params) => read('tts-override', `/internal/chat/moderation/tts-override${qs(params)}`);const readSounds = (params) => read('sounds', `/internal/chat/sounds${qs(params)}`);++const readDmBlockState = (params) => read('dm-block-state', `/internal/chat/dm/block-state${qs(params)}`);/*** GET /internal/chat/sounds/by-command — the approved sound a !command plays. Chat answers its own 404 body* `{ ok: false, error: 'Sound not found' }` for a definitive "no such sound", surfaced as `{ sound: null }`; any@@@ -213,6 +215,6 @@ module.exports = {invalidate: (body) => send('invalidate', body),presence,readStats, readMessages, readTimeline, readFirstChat, readPendingIp, readRelayUsers, readRelayUser, readTtsOverride,-- readSounds, readSoundByCommand, soundAsset,++ readSounds, readSoundByCommand, readDmBlockState, soundAsset,_setRetryMs(ms) { retryMs = ms || RETRY_MS; },};@@@ -16,8 +16,6 @@* logModeration(entry) a moderation-log row (db.logModerationAction shape)* disconnect({ userId, ip, streamId })* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })-- * mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP-- * rows, hidden relay users, first chats, TTS overrides) that Live still reads* after(value, fn) fn(value) now for a plain value, after it resolves for a promise** The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,@@@ -100,21 +98,6 @@ function invalidate(hint, legacy) {return legacy ? legacy() : undefined;}--// The writes Live keeps on its own read-mirror copy as well (Chat applies them too and mirrors back).--const MIRROR_WRITES = new Set([-- 'mergeChatMessageMetadata', 'deleteChatMessage', 'deleteUserChatMessages', 'deleteAnonChatMessages',-- 'deleteRelayUserMessages', 'deleteChatMessagesByTimeRange', 'reviewPendingIpMessage', 'approveAllFromIp',-- 'denyAllFromIp', 'recordRelayUser', 'unhideRelayUser', 'unhideRelayUserByIdentity', 'recordFirstChat',-- 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',--]);----function mirror(fn, ...args) {-- try {-- if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');-- return db[fn](...args);-- } catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }--}--function after(value, fn) {return value && typeof value.then === 'function' ? value.then(fn) : fn(value);}@@@ -325,7 +308,7 @@ async function resolveAnon(ip) {}module.exports = {-- ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, mirror, after, client, MIRROR_WRITES,++ ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, after, client,broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,@@@ -2,14 +2,15 @@/*** The chat reads Live's own features still make, answered from OpenVibe.Chat (plan T3 J4b:* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,-- * pending_ip_messages, hidden_relay_users and tts_voice_overrides, so Live's home stats, recaps,-- * AI context, admin console and mod queues read them through Chat's internal read API-- * (server/chat/chat-client.js). Live's own tables remain as Chat's read mirror until a later step-- * drops them, so the rollback path and the readers that have not moved yet keep working.++ * pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,++ * recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's++ * internal read API (server/chat/chat-client.js). Chat's read mirror was retired on 2026-10-05++ * (Chat #25 removed the sender, Live dropped the receiver), so Live's own copies of the tables Chat++ * owns are frozen and NO remote answer falls back to them.*-- * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise Live runs chat-- * itself and the same call answers from Live's own tables (the rollback / dev path) — the caller-- * never has to know which.++ * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the++ * same call answers from Live's own tables — the caller never has to know which. Unsetting++ * CHAT_AUTHORITY is not a rollback lever: Live runs no chat server any more either way.** Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek@@@ -545,10 +546,11 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {}// ── First chat (the welcome check) ───────────────────────────────────────────--// Chat owns stream_first_chats; the AI context's "first time chatting here" flag asks Chat's--// first-chat read. A false answer (they have chatted here) is stable and cached longer; a true--// answer flips the moment they chat, so it is cached only briefly. The sync caller uses--// firstChatPeek; on a cold cache or a Chat outage it answers Live's own (mirror-kept) table.++// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay++// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached++// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller++// uses firstChatPeek. In chat mode a cold cache or a Chat outage answers false — Live's own copy is++// a frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stableconst FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat@@@ -569,9 +571,12 @@ function firstChat(channelUserId, identity) {if (!channelId || !key) return Promise.resolve(false);return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),-- }).then((v) => (v == null ? firstChatLocal(key, channelId) : v === true));++ }).then((v) => {++ if (v === true || v === false) return v;++ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy++ });}--/** The synchronous form (the AI persona prompt): the cached answer, else Live's own table. */++/** The synchronous form (the AI persona prompt): the cached Chat answer, else Live's own table when Live runs chat. */function firstChatPeek(channelUserId, identity) {const channelId = Number(channelUserId) || 0;const key = String(identity || '');@@@ -579,7 +584,8 @@ function firstChatPeek(channelUserId, identity) {const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),});-- return (v === true || v === false) ? v : firstChatLocal(key, channelId);++ if (v === true || v === false) return v;++ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy}// ── Moderation queues ─────────────────────────────────────────────────────────@@@ -627,32 +633,32 @@ function relayUser(id) {* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay* path can carry a message per second and must not wait on an HTTP call.*-- * Live's own table OR Chat's list (the #28 rule): a hide the mirror applied wins at once even when a-- * cached Chat list fetched just before it lacks the row, and a Chat row hides even before the mirror-- * catches up. A Live-route hide/unhide invalidates the cached list; a cold cache or a Chat outage-- * falls to Live's own table and fails OPEN, never dropping a relayed line.++ * Chat owns hidden_relay_users, so in chat mode Chat's list is the only truth: the fresh answer++ * when there is one, else the last good list while a refresh runs in the background. A cold cache++ * (Chat has never answered) fails OPEN — never drop a relayed line on a Chat outage — and Live's++ * own copy (frozen since the mirror's retirement) is never consulted. A Live-route hide/unhide++ * invalidates the cached list, so the next check re-asks Chat. Live's own table answers only when++ * Live is not in chat mode.*/function isRelayUserHidden(channelId, platform, username) {const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };if (!remote()) return localHidden();-- // Live's own table OR Chat's fresh list — the rule agreed with #28. A fresh Chat list alone-- // would hide a local hide the mirror applied from any other writer (another Live instance, or a-- // write straight in Chat) for up to CACHE_TTL_MS. A Live-route unhide stays immediate: mod-routes-- // drops the `hru:` entry on every hide/unhide, so the next check re-asks Chat. Until Chat has-- // answered (cold cache) or while it is unreachable, Live's own table answers and fails OPEN —-- // never drop a relayed line on a Chat outage.const key = `hru:${Number(channelId) || 0}`;-- const hit = fresh(key);-- if (hit && Array.isArray(hit.value)) {-- if (hit.value.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username))) return true;-- return localHidden(); // no fresh Chat match: a local hide still applies-- }-- load(key, async () => {++ const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));++ const read = async () => {const out = await client.readRelayUsers({ channel_id: Number(channelId) || undefined, limit: 500 });if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');return out.relay_users;-- }, () => []).catch(() => { /* warms the cache for the next check */ });-- return localHidden();++ };++ const hit = fresh(key);++ if (hit && Array.isArray(hit.value)) return matches(hit.value);++ const last = kept(key);++ if (last && Array.isArray(last.value)) {++ load(key, read, () => []).catch(() => { /* refresh for the next check */ });++ return matches(last.value); // the last good list, while the refresh runs++ }++ load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });++ return false; // never answered: fail open, never Live's frozen table}/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */@@@ -667,22 +673,17 @@ function ttsOverride(identityKey) {}/*** The TTS engine's synchronous read: the last good override while Chat refreshes in the background.-- * On a cache miss or a Chat failure it answers Live's own mirror table (which Chat keeps current),-- * so the first line after a restart, an eviction or during a Chat outage is still spoken with the-- * override rather than the auto voice — the same fallback `isRelayUserHidden` uses.++ * In chat mode a cache miss or a Chat failure answers null (the auto voice) — Live's own copy is a++ * frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.*/function ttsOverridePeek(identityKey) {const k = String(identityKey || '').trim().toLowerCase();if (!k) return null;-- const localOverride = () => local().getTtsVoiceOverride(k);-- const got = peek(`ttsp:${k}`, async () => {++ return peek(`ttsp:${k}`, async () => {const out = await client.readTtsOverride({ identity_key: k });if (!out) throw unavailable('tts override');return out.tts_override || null;-- }, localOverride);-- if (got) return got;-- if (!remote()) return null; // peek already answered from Live's own table-- try { return localOverride(); } catch { return null; }++ }, () => local().getTtsVoiceOverride(k));}// ── Channel sounds ────────────────────────────────────────────────────────────@@@ -703,32 +704,56 @@ function soundCount(ownerId) {const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');async function remoteSoundByCommand(ownerId, command) {const out = await client.readSoundByCommand({ channel_id: ownerId, command });-- if (out == null) throw unavailable('sound by command'); // unreachable: keep / fall back++ if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answerreturn out.sound || false; // the sound, or Chat's definitive none}--/** The approved sound a !command plays (or null); Chat owns the row, Live's table is the fallback. */++/** The approved sound a !command plays (or null). Chat owns the row in chat mode; Live's own table answers only when Live runs chat. */function soundByCommand(ownerId, command) {const id = Number(ownerId) || 0;const cmd = normalizeCommand(command);if (!id || !cmd) return Promise.resolve(null);const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;-- return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn).then((v) => {-- if (v === false) return null; // Chat answered: no such sound-- if (v) return v;-- return safeLocal(localFn, null); // cold / outage: Live's own table-- });++ return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn)++ .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable — never Live's frozen row}--/** The synchronous form (the RobotStreamer !sound lookup): cached answer, else Live's own table. */++/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */function soundByCommandPeek(ownerId, command) {const id = Number(ownerId) || 0;const cmd = normalizeCommand(command);if (!id || !cmd) return null;-- const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;-- const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn);-- if (v) return v;-- if (v === false) return null; // Chat answered: no such sound-- if (!remote()) return null; // the peek already answered from Live's own table-- return safeLocal(localFn, null); // cold / Chat unreachable: Live's mirror-filled table++ const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => local().getChannelSoundByCommand(id, cmd) || null);++ return v && v !== false ? v : null;++}++++// ── DM blocks (the call-invite gate) ─────────────────────────────────────────++const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is plenty++const DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly++/**++ * Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's++ * block-state read (chat.messages.read); Live's own dm.js table answers only when Live is not in++ * chat mode. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it —++ * blocks are rare).++ *++ * `strict`: past the cache and with Chat unreachable the answer is null, never the last good++ * value, and the call-invite route fails closed on it — refusing a ring is far better than ringing++ * someone who blocked the caller.++ */++function dmBlocked(a, b) {++ const x = Number(a) || 0;++ const y = Number(b) || 0;++ if (!x || !y) return Promise.resolve(null);++ const key = `dmb:${Math.min(x, y)}:${Math.max(x, y)}`;++ return load(key, async () => {++ const out = await client.readDmBlockState({ a: x, b: y });++ if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');++ return out.blocked;++ }, () => safeLocal(() => require('./dm').isBlockedEither(x, y), null), {++ strict: true,++ ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),++ }).catch((err) => {++ if (err && err.unavailable) return null;++ throw err;++ });}/**@@@ -783,6 +808,6 @@ module.exports = {messageById, channelMessages, channelMessagesPeek, userHistory, relayHistory, channelSamples, searchMessages,liveChatBuckets, recentChatText, spikeOffsets, firstChat, firstChatPeek,pendingIp, relayUsers, relayUser, ttsOverride, ttsOverridePeek, isRelayUserHidden,-- soundCount, soundByCommand, soundByCommandPeek, pendingSounds, recordSoundAsset, invalidate,++ soundCount, soundByCommand, soundByCommandPeek, dmBlocked, pendingSounds, recordSoundAsset, invalidate,_reset, _age, _size, _cacheMax, CACHE_TTL_MS, CACHE_KEEP_MS,};@@@ -26,7 +26,6 @@* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,* media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync-- * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy** Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat* checked first; Live does not take its word for it.@@@ -258,11 +257,7 @@ contextRouter.get('/tts-audio/:file', (req, res) => {// ── Effects ────────────────────────────────────────────────────--effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());--effectsRouter.use((req, res, next) => {-- if (req.path === '/mirror') return next();-- return guard('live.chat_effects.write')(req, res, next);--});++effectsRouter.use(guard('live.chat_effects.write'));// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at// production Live can never award coins, post to AI viewers or overwrite chat tables.effectsRouter.use((req, res, next) => {@@@ -627,84 +622,19 @@ effectsRouter.post('/notify/dm-read', (req, res) => {res.json({ ok: true });});--// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.++// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the++// rows since T3). Chat sends the Media asset id it holds on `asset_id`; Live's own channel_sounds++// copy is a frozen pre-retirement snapshot and is never read for it.effectsRouter.post('/asset-sync', (req, res) => {try {const sync = require('../media-proxy/asset-sync');if (req.body?.op === 'syncSoon') sync.syncSoon();else if (req.body?.op === 'remove-sound') {-- const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);-- if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);++ const assetId = int(req.body.media_asset_id != null ? req.body.media_asset_id : req.body.asset_id);++ if (assetId) sync.removeAsset(assetId);}} catch { /* best-effort */ }res.json({ ok: true });});--// ── The read mirror of Chat's tables ──────────────────────────────--// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id--// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).--// The six staged tables (roadmap C-04) are Chat's now and are not mirrored here; only the C-02--// twelve below still are.--const MIRROR_TABLES = {-- chat_messages: ['id'],-- dm_conversations: ['id'],-- dm_participants: ['id'],-- dm_messages: ['id'],-- dm_blocks: ['id'],-- tts_voice_overrides: ['identity_key'],-- channel_sounds: ['id'],-- relay_users: ['platform', 'username'],-- hidden_relay_users: ['id'],-- pending_ip_messages: ['id'],-- stream_first_chats: ['chatter_key', 'channel_user_id'],-- moderation_actions: ['id'],--};--const _cols = new Map();--function liveColumns(table) {-- if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));-- return _cols.get(table);--}----function applyMirror(changes) {-- const d = db.getDb();-- let applied = 0;-- const skipped = [];-- // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so-- // nothing else runs while they are off.-- d.pragma('foreign_keys = OFF');-- try {-- d.transaction(() => {-- for (const c of changes) {-- const pk = MIRROR_TABLES[c && c.table];-- if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }-- const have = liveColumns(c.table);-- try {-- if (c.op === 'delete') {-- d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));-- } else if (c.op === 'upsert' && c.row) {-- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));-- const upd = cols.filter((k) => !pk.includes(k));-- d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})-- ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)-- .run(...cols.map((k) => c.row[k]));-- } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }-- applied++;-- } catch (err) {-- skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });-- }-- }-- })();-- } finally {-- d.pragma('foreign_keys = ON');-- }-- return { applied, skipped };--}----effectsRouter.post('/mirror', (req, res) => {-- const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];-- const out = applyMirror(changes);-- if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));-- res.json({ ok: true, ...out });--});----module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };++module.exports = { contextRouter, effectsRouter, userProjection };@@@ -24,6 +24,11 @@ const chatReads = require('../chat/chat-reads');const RECONNECT_BASE_MS = 5000;const RECONNECT_MAX_MS = 60000;++// Chatters this process has already welcomed (`<channel user id>:ext:<prefixed username>`), so a++// second line arriving before Chat's first-chat answer reflects the first is not welcomed twice.++const welcomedRelayUsers = new Set();++const WELCOMED_MAX = 5000;++// Platform-specific colors for chat displayconst PLATFORM_COLORS = {twitch: '#9146ff',@@@ -387,8 +392,9 @@ class ChatRelayService {const prefixedUsername = `[${label}] ${username}`;// Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached-- // peek — this path must not wait on an HTTP call), Live's own mirror-fed table otherwise;-- // Chat unreachable falls back to that table, so a Chat outage never un-hides anyone.++ // peek — this path must not wait on an HTTP call): the fresh list, else the last good one.++ // A cold cache fails open, so a Chat outage never drops relayed lines; Live's own frozen++ // table is consulted only when Live is not in chat mode.try {const stream = db.getStreamById(bridge.streamId);const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);@@@ -398,7 +404,11 @@ class ChatRelayService {} catch { /* non-critical — allow message through on error */ }const delivery = require('../chat/chat-delivery');-- if (delivery.ingress()) return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || ''));++ if (delivery.ingress()) {++ // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of++ // the send must not stall it. The catch keeps a surprise rejection off the process.++ return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });++ }// Record this relay user's activity; their first message becomes a join date.try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }@@@ -496,14 +506,25 @@ class ChatRelayService {} catch { /* non-critical */ }}-- /** Chat ingress: Chat persists, broadcasts, mirrors, reads aloud and records the first chat in one call. */-- _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {++ /**++ * Chat ingress: Chat persists, broadcasts, reads aloud and records the first chat in one call.++ * The welcome decision asks Chat's first-chat read BEFORE the message send — Chat records the++ * first chat on that call, so a decision after it would never see `first` — and never Live's++ * own pre-retirement copy. A second line within Chat's short "first" cache window is deduped++ * in-process; the cached answer is dropped after the send so the next check sees Chat's record.++ */++ async _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {let first = false;++ let welcomeKey = null;try {const stream = db.getStreamById(bridge.streamId);-- first = !!(stream?.user_id && db.isFirstChatInChannel(`ext:${prefixedUsername}`, stream.user_id));-- } catch { /* non-critical */ }-- delivery.mirror('recordRelayUser', bridge.platform, username);++ const chatterKey = `ext:${prefixedUsername}`;++ if (stream?.user_id) {++ welcomeKey = `${stream.user_id}:${chatterKey}`;++ first = await chatReads.firstChat(stream.user_id, chatterKey);++ if (first && welcomedRelayUsers.has(welcomeKey)) first = false;++ }++ } catch { /* non-critical — no welcome rather than a wrong one */ }delivery.moderate('relay-record', { platform: bridge.platform, username });delivery.after(delivery.message({stream_id: bridge.streamId, username: prefixedUsername, message, message_type: 'chat', is_global: false,@@@ -512,8 +533,9 @@ class ChatRelayService {tts: { identity_key: `${bridge.platform}:${prefixedUsername}` },}), (id) => this._relayFollowUps(bridge, username, prefixedUsername, message, extras, id));if (first) {-- // Chat records the first chat in its copy; Live's copy decides the next welcome.-- try { delivery.mirror('recordFirstChat', `ext:${prefixedUsername}`, db.getStreamById(bridge.streamId).user_id); } catch { /* */ }++ if (welcomedRelayUsers.size >= WELCOMED_MAX) welcomedRelayUsers.clear();++ welcomedRelayUsers.add(welcomeKey);++ chatReads.invalidate(`fc:${welcomeKey}`); // Chat's cached `first` answer is about to be staledelivery.event({ kind: 'stream', id: bridge.streamId }, {type: 'system',message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,@@@ -655,7 +655,8 @@ class RobotStreamerService {const username = `[RS] ${rawUsername}`;// Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached-- // peek), Live's own mirror-fed table otherwise; Chat unreachable falls back to it.++ // peek — fresh list, else the last good one; a cold cache fails open), Live's own++ // table only when Live is not in chat mode.try {const channel = stream.channel_id? db.getChannelById(stream.channel_id)@@@ -667,7 +668,7 @@ class RobotStreamerService {// Record this relay user (first message = join date) so RobotStreamer// chatters get the same chat logs + AI insight as other relay users.-- if (delivery.ingress()) { delivery.mirror('recordRelayUser', 'rs', rawUsername); delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }++ if (delivery.ingress()) { delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }// Let RobotStreamer viewers trigger channel !sound commands too. If the@@@ -677,8 +678,8 @@ class RobotStreamerService {if (trimmed.startsWith('!')) {const parts = trimmed.split(/\s+/);const scmd = parts[0].slice(1).toLowerCase();-- // Chat owns channel_sounds; ask its by-command read (a cached peek), which falls-- // back to Live's own table while Chat is unreachable.++ // Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own++ // frozen table is never a fallback — null means no sound (or Chat unreachable).if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {if (delivery.ingress()) {delivery.event({ kind: 'stream', id: stream.id }, {@@@ -1368,7 +1368,7 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {});const _callUserRate = new Map(); // userId → [timestamps]--router.post('/voice-channels/call-user', requireAuth, (req, res) => {++router.post('/voice-channels/call-user', requireAuth, async (req, res) => {try {// Six calls a minute per account: a ring is a notification on someone else's screen.const now = Date.now();@@@ -1383,7 +1383,15 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);if (!targetUser) return res.status(404).json({ error: 'User not found' });if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });-- try { const dm = require('../chat/dm'); if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }++ // Blocks live in Chat (dm_blocks is Chat's table), so the check goes through chat-reads. On an++ // unverifiable answer — a stale cache with Chat unreachable — the ring is REFUSED: ringing++ // someone who blocked the caller is the worse failure, and 'try again shortly' is honest.++ const blocked = await chatReads.dmBlocked(req.user.id, targetUser.id).catch(() => null);++ if (blocked !== false) {++ return blocked === true++ ? res.status(403).json({ error: 'You cannot call this user' })++ : res.status(503).json({ error: 'Could not check the block list — try again shortly' });++ }// Reuse caller's existing temp channel if present; otherwise create a private one — a// 1:1 call is not something the whole site should see listed and be able to walk into.@@@ -37,7 +37,6 @@ function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {}const READ = serviceToken(['live.chat_context.read']);const WRITE = serviceToken(['live.chat_effects.write']);--const MIRROR = serviceToken(['live.chat_mirror.write']);// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (its typed ingress).const ingressCalls = [];@@@ -48,7 +47,7 @@ const network = http.createServer((req, res) => {res.setHeader('Content-Type', 'application/json');if (req.url === '/oauth/token') {const f = new URLSearchParams(raw);-- return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));++ return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read', 'chat.messages.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));}res.statusCode = 404; res.end('{}');});@@@ -56,14 +55,15 @@ const network = http.createServer((req, res) => {// Chat's internal read API (roadmap T3 J4b): Live reads stats, queues, history and sounds through// these. The stub answers from Live's own tables (emulating Chat's copy) so the seeds apply, and// `readState.down` fails every read the way a Chat outage does.--const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0 };++const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0, dmBlocked: false, blockStateDown: false };function readReply(req, res, raw) {const url = String(req.url);const path = url.split('?')[0];const q = new URLSearchParams(url.split('?')[1] || '');const isRead = path === '/internal/chat/stats' || path === '/internal/chat/messages' || path === '/internal/chat/first-chat'|| path.startsWith('/internal/chat/moderation/') || path === '/internal/chat/sounds'-- || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset';++ || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset'++ || path === '/internal/chat/dm/block-state';if (!isRead) return false;if (readState.down) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }if (path === '/internal/chat/sounds/asset' && readState.soundAssetDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat sound write unavailable' })); return true; }@@@ -101,6 +101,10 @@ function readReply(req, res, raw) {const r = d2.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters FROM chat_messages WHERE ${where.join(' AND ')}`, params);return ok({ messages: Number(r.messages), chatters: Number(r.chatters) });}++ if (path === '/internal/chat/dm/block-state') {++ if (readState.blockStateDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }++ return ok({ blocked: !!readState.dmBlocked });++ }if (path === '/internal/chat/first-chat') {readState.firstChatCalls++;const r = d2.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);@@@ -211,6 +215,11 @@ const chat = http.createServer((req, res) => {const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++const waitFor = async (pred, what, ms = 3000) => {++ const end = Date.now() + ms;++ while (Date.now() < end) { const v = pred(); if (v) return v; await sleep(10); }++ throw new Error(`timed out waiting for ${what}`);++};(async () => {process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;@@@ -268,6 +277,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));app.use('/internal/chat-context', routes.contextRouter);app.use('/internal/chat-effects', routes.effectsRouter);app.use('/api/mod', require('../server/admin/mod-routes'));++ app.use('/api/streams', require('../server/streaming/routes'));const port = await listen(http.createServer(app));const call = async (method, p, { token, body, headers = {} } = {}) => {const res = await fetch(`http://127.0.0.1:${port}${p}`, {@@@ -400,25 +410,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));[viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));-- // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept. Chat's-- // LIVE_MIRROR is on in production, so the tables it keeps current stay Live's until the-- // later step that removes both the mirror and its remaining readers.-- const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });-- assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');-- let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);-- assert.strictEqual(m.body.applied, 1);-- assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');-- // Columns only Live's copy has (media-proxy/asset-sync adds them at start).-- for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }-- d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);-- m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);-- assert.strictEqual(m.body.applied, 2);-- assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');-- const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();-- assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });-- assert.strictEqual(db.getChatMessageById(900001), undefined);-- assert.ok(db.getUserById(viewer), 'users are never touched');--// 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its// outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).const chatDelivery = require('../server/chat/chat-delivery');@@@ -483,8 +474,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.ok(chatReads.siteStatsPeek(), 'the background warm-up answered');// (2)(4) A TTS override survives a TTL and a Chat outage (the engine reads it-- // synchronously); a cold peek answers Live's own mirror table, not the auto voice.-- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a cold peek falls back to Live\'s own table');++ // synchronously); in chat mode a cold peek answers null, never Live's frozen copy.++ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a cold peek answers null, never Live\'s frozen table');for (let i = 0; i < 300 && !chatReads.ttsOverridePeek('user:viewer'); i++) await sleep(10);assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3');chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'ttsp:');@@@ -530,17 +521,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const cs = await chatReads.channelSamples(streamer, { relay: { platform: 'twitch', rawUsername: 'alice' }, limit: 10 });assert.strictEqual(cs.length, 1, 'a relay clone sample finds the prefixed row');-- // (1) Hidden relay users: Live's mirror row hides at once; Chat's queue hides even when-- // the mirror has not seen it; a Chat outage fails open to Live's own table.-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, 'the mirror row hides at once');++ // (1) Hidden relay users come from Chat's list only in chat mode: a cold cache fails++ // open and warms in the background, and a Chat outage answers the last good list.++ chatReads._reset();++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), false, 'a cold cache fails open');readState.relayOnly = [{ id: 999999, channel_id: channel.id, platform: 'youtube', external_username: 'ghost' }];assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, 'the first check warms Chat in the background');for (let i = 0; i < 300 && !chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'); i++) await sleep(10);-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user Live's mirror has not seen");++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user");++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Chat's copy carries alice (the stub reads the same table)");readState.down = true;chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'hru:');-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, "Chat unreachable fails open to Live's own table");-- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Live's own hidden row still applies");++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, 'Chat unreachable answers the last good list');++ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'nobody'), false, 'a name no list carries stays unhidden');readState.down = false;readState.relayOnly = [];chatReads._reset();@@@ -627,9 +620,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));chatReads._reset();}-- // (4) A cold or outage peek answers Live's own override table, not the auto voice.++ // (4) In chat mode a cold or outage peek answers null, never Live's frozen override table.++ chatReads._reset();readState.down = true;-- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a peek falls back to Live\'s own table');++ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a peek answers null, never Live\'s frozen table');readState.down = false;chatReads._reset();@@@ -741,8 +735,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));chatReads._reset();}-- // (3) Live's own hidden row OR Chat's fresh list — a local hide the mirror applied still-- // hides even when a fresh Chat list lacks it (the #28 rule, restored).++ // (3) Live's own hidden copy is frozen and dead weight in chat mode: a stale local hide++ // that Chat's list does not carry must not hide anyone (the mirror no longer keeps it in step).{const chatClient = require('../server/chat/chat-client');const realReadRelayUsers = chatClient.readRelayUsers;@@@ -752,11 +746,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothingchatReads._reset();try {-- d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);-- chatReads.isRelayUserHidden(freshCh, 'twitch', 'nobody'); // warm Chat's fresh (empty) list-- for (let i = 0; i < 100; i++) await sleep(10);++ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'no local row yet');d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);-- assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own hidden row wins over a fresh Chat list that lacks it");++ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own row is there");++ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open, never Live\'s row');++ for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);++ assert.ok(chatReads._size() > 0, 'the background read warmed the cache');++ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");} finally {chatClient.readRelayUsers = realReadRelayUsers;d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);@@@ -869,50 +865,168 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));await chatReads.firstChat(streamer, `user:${viewer}`);assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');chatReads._reset();-- d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);++ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);++ assert.strictEqual(d2.isFirstChatInChannel(`user:${viewer}`, streamer), true, 'Live\'s frozen table would say first');readState.down = true;-- assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek falls to Live\'s own table');++ assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first, never Live\'s frozen table');readState.down = false;chatReads._reset();// The AI context passes `user:<user_id>`, never the username: a row keyed by the numeric id// suppresses the welcome flag, where the old `user:<username>` key would have missed it.++ // Its peek is synchronous, so each case warms Chat's answer first.d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, channel_user_id, timestamp) VALUES (?, 'viewer', 'hi there', 'chat', ?, ?)", [viewer, streamer, sqlNow()]);await chatReads.channelMessages(streamer, 40);const context = require('../server/ai/context');const ctxStream = db.getStreamById(streamId);const greet = { remember_viewers: true, greet_first_timers: true, max_open_threads: 3, hear_enabled: false };++ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);chatReads.invalidate('fc:');++ await chatReads.firstChat(streamer, `user:${viewer}`); // warm: the context's peek cannot awaitlet tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });assert.ok(!/first time chatting here/.test(tail.text), 'a user with a recorded first chat is not greeted again');d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);chatReads.invalidate('fc:');++ await chatReads.firstChat(streamer, `user:${viewer}`);tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });assert.ok(/first time chatting here/.test(tail.text), 'a new user is flagged first time (user:<id> identity)');chatReads._reset();-- // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek falls-- // to Live's own table. ──++ // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek++ // answers null, never Live's own frozen table. ──d2.run('DELETE FROM channel_sounds WHERE channel_owner_id = ?', [streamer]);d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/h.mp3', ?)", [streamer, streamer]);chatReads._reset();++ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold peek answers null, never Live\'s frozen table');++ for (let i = 0; i < 300 && !chatReads.soundByCommandPeek(streamer, 'honk'); i++) await sleep(10);const honk = chatReads.soundByCommandPeek(streamer, 'honk');-- assert.ok(honk && honk.command === 'honk', 'Chat answers the sound by command');++ assert.ok(honk && honk.command === 'honk', 'the warmed peek answers Chat\'s sound by command');assert.strictEqual(chatReads.soundByCommandPeek(streamer, '!HONK').command, 'honk', 'the command is normalized');assert.strictEqual(await chatReads.soundByCommand(streamer, 'nope'), null, 'Chat: no such sound');d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'nope', '/sounds/n.mp3', ?)", [streamer, streamer]);assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'nope'), null, 'a Chat 404 is cached: Live\'s own row is not used');chatReads._reset();readState.down = true;-- assert.ok(chatReads.soundByCommandPeek(streamer, 'honk'), 'a cold + down peek falls back to Live\'s own table');++ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold + down peek answers null, never Live\'s frozen table');readState.down = false;chatReads._reset();d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'bare404', '/sounds/b.mp3', ?)", [streamer, streamer]);const bare = await chatReads.soundByCommand(streamer, 'bare404');-- assert.ok(bare && bare.command === 'bare404', 'a 404 without Chat\'s own body is not "no such sound": Live\'s table answers');++ assert.strictEqual(bare, null, 'a 404 without Chat\'s own body is not "no such sound": null, never Live\'s table');chatReads._reset();}++ // 11c. The mirror's retirement, checked where it would bite: a stale local row must not++ // answer a chat-mode read, the relay welcome asks Chat's first-chat, an asset removal uses++ // the Media id Chat sent, and a call invite fails closed when the block state is unknown.++ {++ const chatReads = require('../server/chat/chat-reads');++ const chatClient = require('../server/chat/chat-client');++ const d2 = require('../server/db/database');++++ // (1) ttsOverridePeek ignores Live's stale override: Chat says "none".++ {++ const realReadTts = chatClient.readTtsOverride;++ chatClient.readTtsOverride = async () => ({ tts_override: null });++ chatReads._reset();++ d2.run("INSERT OR REPLACE INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:stale', 'en+m7', 99, 200, 0, ?)", [admin]);++ try {++ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, "Chat's 'no override' wins over Live's stale row");++ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, 'and stays null from the cache');++ } finally {++ chatClient.readTtsOverride = realReadTts;++ d2.run("DELETE FROM tts_voice_overrides WHERE identity_key = 'user:stale'");++ chatReads._reset();++ }++ }++++ // (2) The relay welcome asks Chat's first-chat read before the message send; Live's own++ // frozen stream_first_chats neither welcomes nor suppresses.++ {++ const relay = require('../server/integrations/chat-relay-service');++ const realReadFirstChat = chatClient.readFirstChat;++ const realFollowUps = relay._relayFollowUps;++ const welcomes = (name) => ingressCalls.filter((c) => c.family === 'events' && c.body.frame && c.body.frame.type === 'system' && new RegExp(`Welcome ${name}\\b`).test(String(c.body.frame.message)));++ const relayed = (name) => ingressCalls.filter((c) => c.family === 'messages' && c.body.username === `[Twitch] ${name}`);++ relay._relayFollowUps = () => {}; // the welcome decision is what this checks++ chatReads._reset();++ try {++ // Chat: not first, and Live's copy has no row either — nothing to welcome.++ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', ['ext:[Twitch] carol']);++ chatClient.readFirstChat = async () => ({ first: false });++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'carol', 'hello from twitch', {});++ await waitFor(() => relayed('carol').length, 'the relayed line');++ await sleep(50);++ assert.strictEqual(welcomes('carol').length, 0, "Chat's not-first answer suppresses the welcome");++++ // Chat: first, even though Live's frozen copy has a row (it used to suppress it).++ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', ['ext:[Twitch] dave', streamer]);++ chatClient.readFirstChat = async () => ({ first: true });++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'first line', {});++ await waitFor(() => welcomes('dave').length, "dave's welcome");++ // A second line within Chat's short "first" cache window is not welcomed twice.++ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'second line', {});++ await waitFor(() => relayed('dave').length >= 2, 'the second relayed line');++ await sleep(80);++ assert.strictEqual(welcomes('dave').length, 1, "Chat's first-chat answer welcomes once, deduped in-process");++ } finally {++ chatClient.readFirstChat = realReadFirstChat;++ relay._relayFollowUps = realFollowUps;++ d2.run("DELETE FROM stream_first_chats WHERE chatter_key IN ('ext:[Twitch] carol', 'ext:[Twitch] dave')");++ chatReads._reset();++ }++ }++++ // (3) /asset-sync remove-sound deletes the Media asset id Chat sent, never the one on++ // Live's frozen channel_sounds row.++ {++ const media = require('../server/media-client');++ const realRequest = media.request;++ const deleted = [];++ media.request = async (method, p) => { if (method === 'DELETE') deleted.push(p); return { ok: true }; };++ try {++ d2.run("INSERT OR REPLACE INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_asset_id) VALUES (777001, ?, 'given', '/sounds/given.mp3', ?, 424242)", [streamer, streamer]);++ const out = await call('POST', '/internal/chat-effects/asset-sync', { token: WRITE, body: { op: 'remove-sound', asset_id: 555111 } });++ assert.strictEqual(out.status, 200);++ await sleep(20);++ } finally {++ media.request = realRequest;++ d2.run('DELETE FROM channel_sounds WHERE id = 777001');++ }++ assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');++ }++++ // (4) The call invite asks Chat for the block state: blocked refuses, an unknown state++ // (Chat down past the cache) refuses too, never rings through.++ {++ const callerToken = jwt.sign({ sub: String(streamer), username: 'streamer', role: 'streamer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 300 });++ const callUser = (body) => call('POST', '/api/streams/voice-channels/call-user', { token: callerToken, body });++ chatReads._reset();++ readState.dmBlocked = true;++ try {++ const blocked = await callUser({ user_id: viewer });++ assert.strictEqual(blocked.status, 403, 'a blocked pair cannot ring');++ assert.match(blocked.body.error, /cannot call this user/i);++++ readState.dmBlocked = false;++ chatReads._reset();++ const allowed = await callUser({ user_id: viewer });++ assert.strictEqual(allowed.status, 200, `an unblocked pair rings (${allowed.text})`);++ assert.strictEqual(allowed.body.invited, true);++++ readState.down = true;++ chatReads._reset();++ const unknown = await callUser({ user_id: viewer });++ assert.strictEqual(unknown.status, 503, 'an unverifiable block state refuses the ring');++ assert.match(unknown.body.error, /try again shortly/i);++ } finally {++ readState.dmBlocked = false;++ readState.down = false;++ chatReads._reset();++ }++ }++ }++// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.{const sent = [];@@@ -45,11 +45,13 @@ const network = http.createServer((req, res) => {});});--// Stub Chat: records every ingress call; `fail` answers chosen families with a status.++// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its first-chat++// and hidden-relay-user reads answer from Live's own tables (emulating Chat's copy).const calls = [];const bridgeCalls = [];const fail = {};let nextId = 5000;++let firstChatReads = 0;const chat = http.createServer((req, res) => {let raw = '';req.on('data', (c) => { raw += c; });@@@ -60,6 +62,18 @@ const chat = http.createServer((req, res) => {if (req.url === '/internal/live/calls') bridgeCalls.push(JSON.parse(raw));return res.end(JSON.stringify({ ok: true, results: [] }));}++ if (String(req.url).startsWith('/internal/chat/first-chat')) {++ firstChatReads++;++ const q = new URL(req.url, 'http://x').searchParams;++ const d3 = require('../server/db/database');++ const row = d3.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);++ return res.end(JSON.stringify({ ok: true, first: !row }));++ }++ if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {++ const q = new URL(req.url, 'http://x').searchParams;++ const rows = require('../server/db/database').all('SELECT * FROM hidden_relay_users WHERE channel_id = ? OR channel_id IS NULL LIMIT ?', [Number(q.get('channel_id')), Number(q.get('limit') || 100)]);++ return res.end(JSON.stringify({ ok: true, relay_users: rows }));++ }const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);if (!m) { res.statusCode = 404; return res.end('{}'); }const family = m[1];@@@ -151,17 +165,27 @@ function assertSigned(c, what) {assert.deepStrictEqual([rl.body.username, rl.body.frame.role, rl.body.mirror, rl.body.tts.identity_key], ['[Twitch] alice', 'external', true, 'twitch:[Twitch] alice']);assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');-- assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live keeps its first-chat copy');++ assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");++ assert.ok(!d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live writes no first-chat copy of its own in chat mode');++ const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;++ relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});++ await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');++ await sleep(80);++ assert.strictEqual(welcomeCount(), 1, 'a second line within the first-chat cache window is not welcomed twice');// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;++ const chatReads = require('../server/chat/chat-reads');d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);++ chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes the row directly++ await waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");const beforeHidden = relayLines();relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});await sleep(80);assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');assert.strictEqual(relayLines('should not relay'), 0);d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();++ chatReads.invalidate('hru:'); // as the unhide route doesrelay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');@@@ -194,7 +218,7 @@ function assertSigned(c, what) {const del = find('moderation', (b) => b.action === 'delete-message');assertSigned(del, 'message delete');assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);-- assert.strictEqual(db.getChatMessageById(900010).is_deleted, 1, "Live's copy is deleted too (the read mirror is on in production)");++ assert.strictEqual(db.getChatMessageById(900010).is_deleted, 0, "Live's own copy is untouched: Chat owns the delete (the read mirror is retired)");assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });assert.ok(ban.status < 300, `ban answered ${ban.status}`);nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++// T3: the Live end of the retired chat read mirror is gone. OpenVibe.Chat #25 (deployed) removed the++// sender (its LIVE_MIRROR writer and the capture triggers); this change removed Live's receiver —++// POST /internal/chat-effects/mirror and the chat-delivery.js mirror() helper (MIRROR_WRITES) — and++// its call sites. Static guard: nothing under server/ names the receiver, its capability, the mirror++// tables or the helper. Live's own chat tables stay for now (a later change drops them).++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++++const PATTERNS = [++ ['chat-effects/mirror', /chat-effects\/mirror/],++ ['live.chat_mirror.write', /live\.chat_mirror\.write/],++ ['MIRROR_TABLES', /\bMIRROR_TABLES\b/],++ ['applyMirror', /\bapplyMirror\b/],++ ['liveColumns', /\bliveColumns\b/],++ ['MIRROR_WRITES', /\bMIRROR_WRITES\b/],++ ['delivery.mirror', /delivery\.mirror\b/],++];++++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) { walk(p); continue; }++ if (!p.endsWith('.js')) continue;++ const rel = path.relative(ROOT, p);++ const src = fs.readFileSync(p, 'utf8');++ for (const [name, re] of PATTERNS) if (re.test(src)) offenders.push(`${rel}: ${name}`);++ }++})(path.join(ROOT, 'server'));++++assert.deepStrictEqual(offenders, [], `server/ still references the retired chat read mirror:\n${offenders.join('\n')}`);++++console.log('chat mirror removed: nothing under server/ references chat-effects/mirror, live.chat_mirror.write, MIRROR_TABLES/applyMirror/liveColumns/MIRROR_WRITES or delivery.mirror — all checks passed');
@@ -90,7 +90,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -90,7 +90,7 @@ The media subsystem lives in **OpenVibe.Media**:
## Capabilities
## Capabilities
-Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.
+Live implements, for other services' tokens (manifest `manifests/services/live.json` in OpenVibe.Contracts): `live.chat_context.read`, `live.chat_effects.write`, `live.lineage.resolve`, `live.tips_delivery.write`, `live.channel.read`, `live.stream.read`, `live.discovery.read`, `live.owner.resolve`.
Live's own principal (`live`) holds grants to call:
Live's own principal (`live`) holds grants to call:
@@ -10,7 +10,6 @@
@@ -10,7 +10,6 @@
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
- "live.chat_mirror.write",
"live.lineage.resolve",
"live.lineage.resolve",
"live.tips_delivery.write",
"live.tips_delivery.write",
"live.channel.read",
"live.channel.read",
@@ -49,20 +49,20 @@ path are deleted.
@@ -49,20 +49,20 @@ path are deleted.
`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),
`POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`),
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`,
`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through
`/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through
- [server/chat/chat-reads.js](../server/chat/chat-reads.js), in place of the read mirror
- (`POST /internal/chat-effects/mirror`) home stats, recaps, AI context, VOD chat replay and the
- `/api/mod` queues used to read. A Chat outage answers a cached value or Live's own table, never a 500.
-- **What reads the mirror.** Nothing in chat mode any more: the two HOME_SERIES charts `messages`
- and `active` read Chat's site-daily series, the AI context's "first time chatting here" flag reads
- Chat's first-chat read, and the RobotStreamer `!sound` lookup reads Chat's sounds-by-command read —
- each through [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own
- table only while Chat is unreachable. The mirror (`POST /internal/chat-effects/mirror`) therefore
- has no chat-mode reader left and is removed in the remaining step: Chat stops its `LIVE_MIRROR`
- writer, then Live deletes the receiver (`live-context-routes.js` `effectsRouter`'s mirror route and
- its `live.chat_mirror.write` capability) and the mirror-kept chat tables. The readers that still
- answer from Live's tables are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —
- `dm_*` (no `/api/dm`), Live's chat server, the deploy notice, the admin chat log and the leftover
- dev/rollback fallbacks.
+ [server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats,
+ recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value
+ or Live's own table, never a 500.
+- **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR`
+ writer and the capture triggers), and this change removed Live's receiver — the
+ `POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its
+ `live.chat_mirror.write` capability and the `chat-delivery.js` mirror helper. Nothing in chat mode
+ read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series,
+ the AI context's "first time chatting here" flag reads Chat's first-chat read, and the
+ RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through
+ [server/chat/chat-reads.js](../server/chat/chat-reads.js), falling back to Live's own table only
+ while Chat is unreachable. Live's own chat tables stay until a later change drops them; the readers
+ that still answer from them are on the Live-runs-chat path `CHAT_AUTHORITY=chat` leaves unmounted —
+ `dm_*` (no `/api/dm`), the deploy notice, the admin chat log and the leftover dev/rollback fallbacks.
- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,
- **The six chat tables are Chat's** (roadmap T3): `channel_moderators`, `channel_moderation_settings`,
`emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them
`emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events`. Live keeps no copy and reads them
only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal
only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) — Chat's internal
@@ -404,10 +404,8 @@ router.post('/delete-message', async (req, res) => {
@@ -404,10 +404,8 @@ router.post('/delete-message', async (req, res) => {
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this stream' });
}
}
- // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached;
- // the mirror keeps Live's own copy in step for its remaining readers.
+ // Chat ingress: Chat deletes the row and broadcasts the delete to every surface it reached.
if (delivery.ingress()) {
if (delivery.ingress()) {
- delivery.mirror('deleteChatMessage', parseInt(message_id), req.user.id);
await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });
await delivery.moderate('delete-message', { id: parseInt(message_id), deleted_by: req.user.id }, { key: `delete:${message_id}` });
}
}
else db.deleteChatMessage(parseInt(message_id), req.user.id);
else db.deleteChatMessage(parseInt(message_id), req.user.id);
@@ -465,10 +463,6 @@ router.post('/delete-user-messages', async (req, res) => {
@@ -465,10 +463,6 @@ router.post('/delete-user-messages', async (req, res) => {
// Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.
// Chat deletes the rows and broadcasts the deletes (also the site-wide one) itself.
const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]
const [action, subject] = user_id ? ['delete-user-messages', { user_id: parseInt(user_id) }]
: anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];
: anon_id ? ['delete-anon-messages', { anon_id: String(anon_id) }] : ['delete-relay-messages', { username: String(relay_username) }];
- const opts = { streamId: scopedStreamId, deletedBy: req.user.id };
- if (user_id) delivery.mirror('deleteUserChatMessages', parseInt(user_id), opts);
- else if (anon_id) delivery.mirror('deleteAnonChatMessages', anon_id, opts);
- else delivery.mirror('deleteRelayUserMessages', relay_username, opts);
const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };
const scoped = { stream_id: scopedStreamId ? parseInt(scopedStreamId) : undefined, deleted_by: req.user.id };
const r = await delivery.moderate(action, { ...subject, ...scoped });
const r = await delivery.moderate(action, { ...subject, ...scoped });
ids = (r && Array.isArray(r.ids)) ? r.ids : [];
ids = (r && Array.isArray(r.ids)) ? r.ids : [];
@@ -538,7 +532,6 @@ router.post('/relay-user/hide', async (req, res) => {
@@ -538,7 +532,6 @@ router.post('/relay-user/hide', async (req, res) => {
// Unban / unhide — remove the ban for this exact relay identity only.
// Unban / unhide — remove the ban for this exact relay identity only.
if (action === 'unban' || action === 'unhide') {
if (action === 'unban' || action === 'unhide') {
if (delivery.ingress()) {
if (delivery.ingress()) {
- delivery.mirror('unhideRelayUserByIdentity', channel_id || null, platform, external_username);
await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });
await delivery.moderate('relay-unhide', { channel_id: channel_id || undefined, platform, external_username });
}
}
else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);
else db.unhideRelayUserByIdentity(channel_id || null, platform, external_username);
@@ -589,7 +582,7 @@ router.delete('/relay-user/:id', async (req, res) => {
@@ -589,7 +582,7 @@ router.delete('/relay-user/:id', async (req, res) => {
const allowed = permissions.isGlobalModOrAbove(req.user)
const allowed = permissions.isGlobalModOrAbove(req.user)
|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
|| (row.channel_id != null && await permissions.canModerateChannel(req.user, row.channel_id));
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
if (!allowed) return res.status(403).json({ error: 'You cannot moderate this channel' });
- if (delivery.ingress()) { delivery.mirror('unhideRelayUser', row.id); await delivery.moderate('relay-unhide', { id: row.id }); }
+ if (delivery.ingress()) { await delivery.moderate('relay-unhide', { id: row.id }); }
else db.unhideRelayUser(row.id);
else db.unhideRelayUser(row.id);
invalidateRelayHidden(row.channel_id);
invalidateRelayHidden(row.channel_id);
@@ -676,7 +669,6 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
@@ -676,7 +669,6 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
// Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).
// Chat ingress: Chat approves its held rows; re-showing them live is Chat's (it holds them).
if (delivery.ingress()) {
if (delivery.ingress()) {
- delivery.mirror('approveAllFromIp', channelId, ip, req.user.id);
await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
await delivery.moderate('approve-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
}
}
else db.approveAllFromIp(channelId, ip, req.user.id);
else db.approveAllFromIp(channelId, ip, req.user.id);
@@ -732,7 +724,6 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {
@@ -732,7 +724,6 @@ router.post('/ip-approval/:channelId/deny', async (req, res) => {
}
}
if (delivery.ingress()) {
if (delivery.ingress()) {
- delivery.mirror('denyAllFromIp', channelId, ip, req.user.id);
await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
await delivery.moderate('deny-ip-messages', { channel_id: parseInt(channelId), ip, reviewed_by: req.user.id });
}
}
else db.denyAllFromIp(channelId, ip, req.user.id);
else db.denyAllFromIp(channelId, ip, req.user.id);
@@ -768,7 +759,6 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {
@@ -768,7 +759,6 @@ router.post('/ip-approval/:channelId/review', async (req, res) => {
}
}
if (delivery.ingress()) {
if (delivery.ingress()) {
- delivery.mirror('reviewPendingIpMessage', parseInt(message_id), { status, reviewedBy: req.user.id, channelId });
await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });
await delivery.moderate('review-pending-ip', { id: parseInt(message_id), status, reviewed_by: req.user.id, channel_id: parseInt(channelId) });
} else db.reviewPendingIpMessage(parseInt(message_id), {
} else db.reviewPendingIpMessage(parseInt(message_id), {
status,
status,
@@ -973,7 +963,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
@@ -973,7 +963,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
try {
try {
const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);
const identityKey = _ttsIdentityKey(req.params.kind, req.params.id);
if (req.body?.reset) {
if (req.body?.reset) {
- if (delivery.ingress()) { delivery.mirror('deleteTtsVoiceOverride', identityKey); delivery.moderate('tts-voice-override', { identity_key: identityKey }); }
+ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey }); }
else db.deleteTtsVoiceOverride(identityKey);
else db.deleteTtsVoiceOverride(identityKey);
return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });
return res.json({ isOverride: false, params: ttsEngine.autoUserVoiceParams(identityKey) });
}
}
@@ -991,7 +981,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
@@ -991,7 +981,7 @@ router.put('/tts-voice/:kind/:id', permissions.requireGlobalMod, (req, res) => {
params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };
params = { voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap };
}
}
const clamped = ttsEngine.clampVoiceParams(params);
const clamped = ttsEngine.clampVoiceParams(params);
- if (delivery.ingress()) { delivery.mirror('setTtsVoiceOverride', identityKey, clamped, req.user.id); delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }
+ if (delivery.ingress()) { delivery.moderate('tts-voice-override', { identity_key: identityKey, params: clamped, set_by: req.user.id }); }
else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);
else db.setTtsVoiceOverride(identityKey, clamped, req.user.id);
res.json({ isOverride: true, params: clamped });
res.json({ isOverride: true, params: clamped });
} catch (err) {
} catch (err) {
@@ -205,7 +205,8 @@ function personBlock(line, channelUserId, settings) {
@@ -205,7 +205,8 @@ function personBlock(line, channelUserId, settings) {
}
}
// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the
// stream_first_chats is Chat's; a registered identity is `user:<user_id>` (never the
// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat
// username), a relay one `ext:<prefixed username>`. The welcome flag reads Chat's first-chat
- // read through a sync peek that falls back to Live's own mirror-kept table while Chat is down.
+ // read through a sync peek; a cold cache or a Chat outage answers "not first" and never
+ // Live's own frozen copy.
const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
const identity = line.userId ? `user:${line.userId}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');
if (line.userId !== channelUserId && settings.greet_first_timers && chatReads.firstChatPeek(channelUserId, identity)) flags.push('first time chatting here');
} catch { /* */ }
} catch { /* */ }
@@ -10,9 +10,8 @@
@@ -10,9 +10,8 @@
* presence reads and the address/anon helpers Live's modules used on its old chat server — Live
* presence reads and the address/anon helpers Live's modules used on its old chat server — Live
* runs no chat server in any mode;
* runs no chat server in any mode;
* - reads chat stats, queues and history from Chat's internal read API
* - reads chat stats, queues and history from Chat's internal read API
- * (server/chat/chat-reads.js → chat-client.js), and still keeps its chat tables as a read
- * mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved
- * yet keep working and a rollback loses nothing;
+ * (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
+ * retired on 2026-10-05 and Live keeps its chat tables only until a later change drops them;
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
* Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
* is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
* is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
* readTtsOverride(q) GET /internal/chat/moderation/tts-override chat.moderation.queue.read a voice override
* readTtsOverride(q) GET /internal/chat/moderation/tts-override chat.moderation.queue.read a voice override
* readSounds(q) GET /internal/chat/sounds chat.sounds.read a channel's sound count, or its pending assets
* readSounds(q) GET /internal/chat/sounds chat.sounds.read a channel's sound count, or its pending assets
* readSoundByCommand(q) GET /internal/chat/sounds/by-command chat.sounds.read the approved sound a !command plays
* readSoundByCommand(q) GET /internal/chat/sounds/by-command chat.sounds.read the approved sound a !command plays
+ * readDmBlockState(q) GET /internal/chat/dm/block-state chat.messages.read has either user blocked the other (either direction)
*
*
* Auth: Live's Network service principal (client_credentials, audience openvibe.chat, server/net/network-principal.js).
* Auth: Live's Network service principal (client_credentials, audience openvibe.chat, server/net/network-principal.js).
* Every POST carries one idempotency `key` made when the operation is created and reused on every retry, so Chat
* Every POST carries one idempotency `key` made when the operation is created and reused on every retry, so Chat
@@ -175,6 +176,7 @@ const readRelayUsers = (params) => read('relay-users', `/internal/chat/moderatio
@@ -175,6 +176,7 @@ const readRelayUsers = (params) => read('relay-users', `/internal/chat/moderatio
const readRelayUser = (id) => read('relay-user', `/internal/chat/moderation/relay-users/${Number(id)}`);
const readRelayUser = (id) => read('relay-user', `/internal/chat/moderation/relay-users/${Number(id)}`);
const readTtsOverride = (params) => read('tts-override', `/internal/chat/moderation/tts-override${qs(params)}`);
const readTtsOverride = (params) => read('tts-override', `/internal/chat/moderation/tts-override${qs(params)}`);
const readSounds = (params) => read('sounds', `/internal/chat/sounds${qs(params)}`);
const readSounds = (params) => read('sounds', `/internal/chat/sounds${qs(params)}`);
+const readDmBlockState = (params) => read('dm-block-state', `/internal/chat/dm/block-state${qs(params)}`);
/**
/**
* GET /internal/chat/sounds/by-command — the approved sound a !command plays. Chat answers its own 404 body
* GET /internal/chat/sounds/by-command — the approved sound a !command plays. Chat answers its own 404 body
* `{ ok: false, error: 'Sound not found' }` for a definitive "no such sound", surfaced as `{ sound: null }`; any
* `{ ok: false, error: 'Sound not found' }` for a definitive "no such sound", surfaced as `{ sound: null }`; any
@@ -213,6 +215,6 @@ module.exports = {
@@ -213,6 +215,6 @@ module.exports = {
invalidate: (body) => send('invalidate', body),
invalidate: (body) => send('invalidate', body),
presence,
presence,
readStats, readMessages, readTimeline, readFirstChat, readPendingIp, readRelayUsers, readRelayUser, readTtsOverride,
readStats, readMessages, readTimeline, readFirstChat, readPendingIp, readRelayUsers, readRelayUser, readTtsOverride,
- readSounds, readSoundByCommand, soundAsset,
+ readSounds, readSoundByCommand, readDmBlockState, soundAsset,
_setRetryMs(ms) { retryMs = ms || RETRY_MS; },
_setRetryMs(ms) { retryMs = ms || RETRY_MS; },
};
};
@@ -16,8 +16,6 @@
@@ -16,8 +16,6 @@
* logModeration(entry) a moderation-log row (db.logModerationAction shape)
* logModeration(entry) a moderation-log row (db.logModerationAction shape)
* disconnect({ userId, ip, streamId })
* disconnect({ userId, ip, streamId })
* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
* invalidate(hint, legacy) a cache hint ({ user, user_data, approvals, bans, channel })
- * mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP
- * rows, hidden relay users, first chats, TTS overrides) that Live still reads
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
*
*
* The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,
* The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,
@@ -100,21 +98,6 @@ function invalidate(hint, legacy) {
@@ -100,21 +98,6 @@ function invalidate(hint, legacy) {
return legacy ? legacy() : undefined;
return legacy ? legacy() : undefined;
}
}
-// The writes Live keeps on its own read-mirror copy as well (Chat applies them too and mirrors back).
-const MIRROR_WRITES = new Set([
- 'mergeChatMessageMetadata', 'deleteChatMessage', 'deleteUserChatMessages', 'deleteAnonChatMessages',
- 'deleteRelayUserMessages', 'deleteChatMessagesByTimeRange', 'reviewPendingIpMessage', 'approveAllFromIp',
- 'denyAllFromIp', 'recordRelayUser', 'unhideRelayUser', 'unhideRelayUserByIdentity', 'recordFirstChat',
- 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',
-]);
-
-function mirror(fn, ...args) {
- try {
- if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');
- return db[fn](...args);
- } catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }
-}
-
function after(value, fn) {
function after(value, fn) {
return value && typeof value.then === 'function' ? value.then(fn) : fn(value);
return value && typeof value.then === 'function' ? value.then(fn) : fn(value);
}
}
@@ -325,7 +308,7 @@ async function resolveAnon(ip) {
@@ -325,7 +308,7 @@ async function resolveAnon(ip) {
}
}
module.exports = {
module.exports = {
- ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, mirror, after, client, MIRROR_WRITES,
+ ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, after, client,
broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
@@ -2,14 +2,15 @@
@@ -2,14 +2,15 @@
/**
/**
* The chat reads Live's own features still make, answered from OpenVibe.Chat (plan T3 J4b:
* The chat reads Live's own features still make, answered from OpenVibe.Chat (plan T3 J4b:
* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,
* "Read Live's chat stats, queues and history from Chat"). Chat owns chat_messages, channel_sounds,
- * pending_ip_messages, hidden_relay_users and tts_voice_overrides, so Live's home stats, recaps,
- * AI context, admin console and mod queues read them through Chat's internal read API
- * (server/chat/chat-client.js). Live's own tables remain as Chat's read mirror until a later step
- * drops them, so the rollback path and the readers that have not moved yet keep working.
+ * pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,
+ * recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
+ * internal read API (server/chat/chat-client.js). Chat's read mirror was retired on 2026-10-05
+ * (Chat #25 removed the sender, Live dropped the receiver), so Live's own copies of the tables Chat
+ * owns are frozen and NO remote answer falls back to them.
*
*
- * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise Live runs chat
- * itself and the same call answers from Live's own tables (the rollback / dev path) — the caller
- * never has to know which.
+ * Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
+ * same call answers from Live's own tables — the caller never has to know which. Unsetting
+ * CHAT_AUTHORITY is not a rollback lever: Live runs no chat server any more either way.
*
*
* Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek
* Caching: an answer is fresh for CACHE_TTL_MS. An async read past that asks Chat again; a peek
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
* (for a caller that cannot await) answers the last good value while that refresh runs, so a peek
@@ -545,10 +546,11 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {
@@ -545,10 +546,11 @@ function spikeOffsets(streamId, bucketSec = 30, topN = 8, sinceMs) {
}
}
// ── First chat (the welcome check) ───────────────────────────────────────────
// ── First chat (the welcome check) ───────────────────────────────────────────
-// Chat owns stream_first_chats; the AI context's "first time chatting here" flag asks Chat's
-// first-chat read. A false answer (they have chatted here) is stable and cached longer; a true
-// answer flips the moment they chat, so it is cached only briefly. The sync caller uses
-// firstChatPeek; on a cold cache or a Chat outage it answers Live's own (mirror-kept) table.
+// Chat owns stream_first_chats; the AI context's "first time chatting here" flag and the relay
+// welcome ask Chat's first-chat read. A false answer (they have chatted here) is stable and cached
+// longer; a true answer flips the moment they chat, so it is cached only briefly. The sync caller
+// uses firstChatPeek. In chat mode a cold cache or a Chat outage answers false — Live's own copy is
+// a frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stable
const FIRST_CHAT_TTL_FALSE = 5 * 60_000; // has chatted here: stable
const FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat
const FIRST_CHAT_TTL_TRUE = CACHE_TTL_MS; // first time: flips as soon as they chat
@@ -569,9 +571,12 @@ function firstChat(channelUserId, identity) {
@@ -569,9 +571,12 @@ function firstChat(channelUserId, identity) {
if (!channelId || !key) return Promise.resolve(false);
if (!channelId || !key) return Promise.resolve(false);
return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
return load(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (v) => (v === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
- }).then((v) => (v == null ? firstChatLocal(key, channelId) : v === true));
+ }).then((v) => {
+ if (v === true || v === false) return v;
+ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
+ });
}
}
-/** The synchronous form (the AI persona prompt): the cached answer, else Live's own table. */
+/** The synchronous form (the AI persona prompt): the cached Chat answer, else Live's own table when Live runs chat. */
function firstChatPeek(channelUserId, identity) {
function firstChatPeek(channelUserId, identity) {
const channelId = Number(channelUserId) || 0;
const channelId = Number(channelUserId) || 0;
const key = String(identity || '');
const key = String(identity || '');
@@ -579,7 +584,8 @@ function firstChatPeek(channelUserId, identity) {
@@ -579,7 +584,8 @@ function firstChatPeek(channelUserId, identity) {
const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
const v = peek(`fc:${channelId}:${key}`, firstChatRemote(channelId, key), () => firstChatLocal(key, channelId), {
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
ttl: FIRST_CHAT_TTL_TRUE, ttlFor: (x) => (x === false ? FIRST_CHAT_TTL_FALSE : FIRST_CHAT_TTL_TRUE),
});
});
- return (v === true || v === false) ? v : firstChatLocal(key, channelId);
+ if (v === true || v === false) return v;
+ return remote() ? false : firstChatLocal(key, channelId); // Chat unreachable: not first, never Live's frozen copy
}
}
// ── Moderation queues ─────────────────────────────────────────────────────────
// ── Moderation queues ─────────────────────────────────────────────────────────
@@ -627,32 +633,32 @@ function relayUser(id) {
@@ -627,32 +633,32 @@ function relayUser(id) {
* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay
* Is this relay identity hidden (banned) in this channel or site-wide? Synchronous: the relay
* path can carry a message per second and must not wait on an HTTP call.
* path can carry a message per second and must not wait on an HTTP call.
*
*
- * Live's own table OR Chat's list (the #28 rule): a hide the mirror applied wins at once even when a
- * cached Chat list fetched just before it lacks the row, and a Chat row hides even before the mirror
- * catches up. A Live-route hide/unhide invalidates the cached list; a cold cache or a Chat outage
- * falls to Live's own table and fails OPEN, never dropping a relayed line.
+ * Chat owns hidden_relay_users, so in chat mode Chat's list is the only truth: the fresh answer
+ * when there is one, else the last good list while a refresh runs in the background. A cold cache
+ * (Chat has never answered) fails OPEN — never drop a relayed line on a Chat outage — and Live's
+ * own copy (frozen since the mirror's retirement) is never consulted. A Live-route hide/unhide
+ * invalidates the cached list, so the next check re-asks Chat. Live's own table answers only when
+ * Live is not in chat mode.
*/
*/
function isRelayUserHidden(channelId, platform, username) {
function isRelayUserHidden(channelId, platform, username) {
const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };
const localHidden = () => { try { return !!local().isRelayUserHidden(channelId, platform, username); } catch { return false; } };
if (!remote()) return localHidden();
if (!remote()) return localHidden();
- // Live's own table OR Chat's fresh list — the rule agreed with #28. A fresh Chat list alone
- // would hide a local hide the mirror applied from any other writer (another Live instance, or a
- // write straight in Chat) for up to CACHE_TTL_MS. A Live-route unhide stays immediate: mod-routes
- // drops the `hru:` entry on every hide/unhide, so the next check re-asks Chat. Until Chat has
- // answered (cold cache) or while it is unreachable, Live's own table answers and fails OPEN —
- // never drop a relayed line on a Chat outage.
const key = `hru:${Number(channelId) || 0}`;
const key = `hru:${Number(channelId) || 0}`;
- const hit = fresh(key);
- if (hit && Array.isArray(hit.value)) {
- if (hit.value.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username))) return true;
- return localHidden(); // no fresh Chat match: a local hide still applies
- }
- load(key, async () => {
+ const matches = (list) => list.some((r) => String(r.platform) === String(platform) && String(r.external_username) === String(username));
+ const read = async () => {
const out = await client.readRelayUsers({ channel_id: Number(channelId) || undefined, limit: 500 });
const out = await client.readRelayUsers({ channel_id: Number(channelId) || undefined, limit: 500 });
if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');
if (!out || !Array.isArray(out.relay_users)) throw unavailable('hidden relay users');
return out.relay_users;
return out.relay_users;
- }, () => []).catch(() => { /* warms the cache for the next check */ });
- return localHidden();
+ };
+ const hit = fresh(key);
+ if (hit && Array.isArray(hit.value)) return matches(hit.value);
+ const last = kept(key);
+ if (last && Array.isArray(last.value)) {
+ load(key, read, () => []).catch(() => { /* refresh for the next check */ });
+ return matches(last.value); // the last good list, while the refresh runs
+ }
+ load(key, read, () => []).catch(() => { /* warms the cache for the next check */ });
+ return false; // never answered: fail open, never Live's frozen table
}
}
/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */
/** A user's TTS voice override: { voice, pitch, speed, gap } | null. Live, never cached. */
@@ -667,22 +673,17 @@ function ttsOverride(identityKey) {
@@ -667,22 +673,17 @@ function ttsOverride(identityKey) {
}
}
/**
/**
* The TTS engine's synchronous read: the last good override while Chat refreshes in the background.
* The TTS engine's synchronous read: the last good override while Chat refreshes in the background.
- * On a cache miss or a Chat failure it answers Live's own mirror table (which Chat keeps current),
- * so the first line after a restart, an eviction or during a Chat outage is still spoken with the
- * override rather than the auto voice — the same fallback `isRelayUserHidden` uses.
+ * In chat mode a cache miss or a Chat failure answers null (the auto voice) — Live's own copy is a
+ * frozen pre-retirement snapshot and is never consulted; it answers only when Live runs chat.
*/
*/
function ttsOverridePeek(identityKey) {
function ttsOverridePeek(identityKey) {
const k = String(identityKey || '').trim().toLowerCase();
const k = String(identityKey || '').trim().toLowerCase();
if (!k) return null;
if (!k) return null;
- const localOverride = () => local().getTtsVoiceOverride(k);
- const got = peek(`ttsp:${k}`, async () => {
+ return peek(`ttsp:${k}`, async () => {
const out = await client.readTtsOverride({ identity_key: k });
const out = await client.readTtsOverride({ identity_key: k });
if (!out) throw unavailable('tts override');
if (!out) throw unavailable('tts override');
return out.tts_override || null;
return out.tts_override || null;
- }, localOverride);
- if (got) return got;
- if (!remote()) return null; // peek already answered from Live's own table
- try { return localOverride(); } catch { return null; }
+ }, () => local().getTtsVoiceOverride(k));
}
}
// ── Channel sounds ────────────────────────────────────────────────────────────
// ── Channel sounds ────────────────────────────────────────────────────────────
@@ -703,32 +704,56 @@ function soundCount(ownerId) {
@@ -703,32 +704,56 @@ function soundCount(ownerId) {
const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');
const normalizeCommand = (command) => String(command || '').trim().toLowerCase().replace(/^!+/, '');
async function remoteSoundByCommand(ownerId, command) {
async function remoteSoundByCommand(ownerId, command) {
const out = await client.readSoundByCommand({ channel_id: ownerId, command });
const out = await client.readSoundByCommand({ channel_id: ownerId, command });
- if (out == null) throw unavailable('sound by command'); // unreachable: keep / fall back
+ if (out == null) throw unavailable('sound by command'); // unreachable: keep the last good answer
return out.sound || false; // the sound, or Chat's definitive none
return out.sound || false; // the sound, or Chat's definitive none
}
}
-/** The approved sound a !command plays (or null); Chat owns the row, Live's table is the fallback. */
+/** The approved sound a !command plays (or null). Chat owns the row in chat mode; Live's own table answers only when Live runs chat. */
function soundByCommand(ownerId, command) {
function soundByCommand(ownerId, command) {
const id = Number(ownerId) || 0;
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
const cmd = normalizeCommand(command);
if (!id || !cmd) return Promise.resolve(null);
if (!id || !cmd) return Promise.resolve(null);
const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
- return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn).then((v) => {
- if (v === false) return null; // Chat answered: no such sound
- if (v) return v;
- return safeLocal(localFn, null); // cold / outage: Live's own table
- });
+ return load(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn)
+ .then((v) => (v && v !== false ? v : null)); // false / null: Chat's none, or unreachable — never Live's frozen row
}
}
-/** The synchronous form (the RobotStreamer !sound lookup): cached answer, else Live's own table. */
+/** The synchronous form (the RobotStreamer !sound lookup): the cached Chat answer, else null. */
function soundByCommandPeek(ownerId, command) {
function soundByCommandPeek(ownerId, command) {
const id = Number(ownerId) || 0;
const id = Number(ownerId) || 0;
const cmd = normalizeCommand(command);
const cmd = normalizeCommand(command);
if (!id || !cmd) return null;
if (!id || !cmd) return null;
- const localFn = () => local().getChannelSoundByCommand(id, cmd) || null;
- const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), localFn);
- if (v) return v;
- if (v === false) return null; // Chat answered: no such sound
- if (!remote()) return null; // the peek already answered from Live's own table
- return safeLocal(localFn, null); // cold / Chat unreachable: Live's mirror-filled table
+ const v = peek(`sbc:${id}:${cmd}`, () => remoteSoundByCommand(id, cmd), () => local().getChannelSoundByCommand(id, cmd) || null);
+ return v && v !== false ? v : null;
+}
+
+// ── DM blocks (the call-invite gate) ─────────────────────────────────────────
+const DM_BLOCK_TTL_FALSE = 30_000; // not blocked: blocks are rare, 30 s is plenty
+const DM_BLOCK_TTL_TRUE = 5_000; // blocked: an unblock should lift a ring quickly
+/**
+ * Has either of these users blocked the other? Chat owns dm_blocks, so chat mode asks Chat's
+ * block-state read (chat.messages.read); Live's own dm.js table answers only when Live is not in
+ * chat mode. Cached briefly (30 s when not blocked, 5 s when blocked; nothing invalidates it —
+ * blocks are rare).
+ *
+ * `strict`: past the cache and with Chat unreachable the answer is null, never the last good
+ * value, and the call-invite route fails closed on it — refusing a ring is far better than ringing
+ * someone who blocked the caller.
+ */
+function dmBlocked(a, b) {
+ const x = Number(a) || 0;
+ const y = Number(b) || 0;
+ if (!x || !y) return Promise.resolve(null);
+ const key = `dmb:${Math.min(x, y)}:${Math.max(x, y)}`;
+ return load(key, async () => {
+ const out = await client.readDmBlockState({ a: x, b: y });
+ if (!out || typeof out.blocked !== 'boolean') throw unavailable('dm block state');
+ return out.blocked;
+ }, () => safeLocal(() => require('./dm').isBlockedEither(x, y), null), {
+ strict: true,
+ ttlFor: (v) => (v === true ? DM_BLOCK_TTL_TRUE : DM_BLOCK_TTL_FALSE),
+ }).catch((err) => {
+ if (err && err.unavailable) return null;
+ throw err;
+ });
}
}
/**
/**
@@ -783,6 +808,6 @@ module.exports = {
@@ -783,6 +808,6 @@ module.exports = {
messageById, channelMessages, channelMessagesPeek, userHistory, relayHistory, channelSamples, searchMessages,
messageById, channelMessages, channelMessagesPeek, userHistory, relayHistory, channelSamples, searchMessages,
liveChatBuckets, recentChatText, spikeOffsets, firstChat, firstChatPeek,
liveChatBuckets, recentChatText, spikeOffsets, firstChat, firstChatPeek,
pendingIp, relayUsers, relayUser, ttsOverride, ttsOverridePeek, isRelayUserHidden,
pendingIp, relayUsers, relayUser, ttsOverride, ttsOverridePeek, isRelayUserHidden,
- soundCount, soundByCommand, soundByCommandPeek, pendingSounds, recordSoundAsset, invalidate,
+ soundCount, soundByCommand, soundByCommandPeek, dmBlocked, pendingSounds, recordSoundAsset, invalidate,
_reset, _age, _size, _cacheMax, CACHE_TTL_MS, CACHE_KEEP_MS,
_reset, _age, _size, _cacheMax, CACHE_TTL_MS, CACHE_KEEP_MS,
};
};
@@ -26,7 +26,6 @@
@@ -26,7 +26,6 @@
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
* media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
* media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
- * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
*
*
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* checked first; Live does not take its word for it.
* checked first; Live does not take its word for it.
@@ -258,11 +257,7 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
@@ -258,11 +257,7 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
// ── Effects ────────────────────────────────────────────────────
// ── Effects ────────────────────────────────────────────────────
-effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());
-effectsRouter.use((req, res, next) => {
- if (req.path === '/mirror') return next();
- return guard('live.chat_effects.write')(req, res, next);
-});
+effectsRouter.use(guard('live.chat_effects.write'));
// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at
// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at
// production Live can never award coins, post to AI viewers or overwrite chat tables.
// production Live can never award coins, post to AI viewers or overwrite chat tables.
effectsRouter.use((req, res, next) => {
effectsRouter.use((req, res, next) => {
@@ -627,84 +622,19 @@ effectsRouter.post('/notify/dm-read', (req, res) => {
@@ -627,84 +622,19 @@ effectsRouter.post('/notify/dm-read', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
-// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.
+// Channel sound files are mirrored to OpenVibe.Media from Chat's channel_sounds (Chat owns the
+// rows since T3). Chat sends the Media asset id it holds on `asset_id`; Live's own channel_sounds
+// copy is a frozen pre-retirement snapshot and is never read for it.
effectsRouter.post('/asset-sync', (req, res) => {
effectsRouter.post('/asset-sync', (req, res) => {
try {
try {
const sync = require('../media-proxy/asset-sync');
const sync = require('../media-proxy/asset-sync');
if (req.body?.op === 'syncSoon') sync.syncSoon();
if (req.body?.op === 'syncSoon') sync.syncSoon();
else if (req.body?.op === 'remove-sound') {
else if (req.body?.op === 'remove-sound') {
- const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);
- if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);
+ const assetId = int(req.body.media_asset_id != null ? req.body.media_asset_id : req.body.asset_id);
+ if (assetId) sync.removeAsset(assetId);
}
}
} catch { /* best-effort */ }
} catch { /* best-effort */ }
res.json({ ok: true });
res.json({ ok: true });
});
});
-// ── The read mirror of Chat's tables ──────────────────────────────
-// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
-// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
-// The six staged tables (roadmap C-04) are Chat's now and are not mirrored here; only the C-02
-// twelve below still are.
-const MIRROR_TABLES = {
- chat_messages: ['id'],
- dm_conversations: ['id'],
- dm_participants: ['id'],
- dm_messages: ['id'],
- dm_blocks: ['id'],
- tts_voice_overrides: ['identity_key'],
- channel_sounds: ['id'],
- relay_users: ['platform', 'username'],
- hidden_relay_users: ['id'],
- pending_ip_messages: ['id'],
- stream_first_chats: ['chatter_key', 'channel_user_id'],
- moderation_actions: ['id'],
-};
-const _cols = new Map();
-function liveColumns(table) {
- if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
- return _cols.get(table);
-}
-
-function applyMirror(changes) {
- const d = db.getDb();
- let applied = 0;
- const skipped = [];
- // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so
- // nothing else runs while they are off.
- d.pragma('foreign_keys = OFF');
- try {
- d.transaction(() => {
- for (const c of changes) {
- const pk = MIRROR_TABLES[c && c.table];
- if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }
- const have = liveColumns(c.table);
- try {
- if (c.op === 'delete') {
- d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
- } else if (c.op === 'upsert' && c.row) {
- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
- const upd = cols.filter((k) => !pk.includes(k));
- d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})
- ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)
- .run(...cols.map((k) => c.row[k]));
- } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }
- applied++;
- } catch (err) {
- skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });
- }
- }
- })();
- } finally {
- d.pragma('foreign_keys = ON');
- }
- return { applied, skipped };
-}
-
-effectsRouter.post('/mirror', (req, res) => {
- const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];
- const out = applyMirror(changes);
- if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));
- res.json({ ok: true, ...out });
-});
-
-module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };
+module.exports = { contextRouter, effectsRouter, userProjection };
@@ -24,6 +24,11 @@ const chatReads = require('../chat/chat-reads');
@@ -24,6 +24,11 @@ const chatReads = require('../chat/chat-reads');
const RECONNECT_BASE_MS = 5000;
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 60000;
const RECONNECT_MAX_MS = 60000;
+// Chatters this process has already welcomed (`<channel user id>:ext:<prefixed username>`), so a
+// second line arriving before Chat's first-chat answer reflects the first is not welcomed twice.
+const welcomedRelayUsers = new Set();
+const WELCOMED_MAX = 5000;
+
// Platform-specific colors for chat display
// Platform-specific colors for chat display
const PLATFORM_COLORS = {
const PLATFORM_COLORS = {
twitch: '#9146ff',
twitch: '#9146ff',
@@ -387,8 +392,9 @@ class ChatRelayService {
@@ -387,8 +392,9 @@ class ChatRelayService {
const prefixedUsername = `[${label}] ${username}`;
const prefixedUsername = `[${label}] ${username}`;
// Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached
// Check if this relay user is hidden/banned. Chat's queue answers in chat mode (a cached
- // peek — this path must not wait on an HTTP call), Live's own mirror-fed table otherwise;
- // Chat unreachable falls back to that table, so a Chat outage never un-hides anyone.
+ // peek — this path must not wait on an HTTP call): the fresh list, else the last good one.
+ // A cold cache fails open, so a Chat outage never drops relayed lines; Live's own frozen
+ // table is consulted only when Live is not in chat mode.
try {
try {
const stream = db.getStreamById(bridge.streamId);
const stream = db.getStreamById(bridge.streamId);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
@@ -398,7 +404,11 @@ class ChatRelayService {
@@ -398,7 +404,11 @@ class ChatRelayService {
} catch { /* non-critical — allow message through on error */ }
} catch { /* non-critical — allow message through on error */ }
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || ''));
+ if (delivery.ingress()) {
+ // Not awaited: the relay socket hands lines in sequence, and the welcome check ahead of
+ // the send must not stall it. The catch keeps a surprise rejection off the process.
+ return this._deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, String(message || '')).catch(() => { /* logged in chat-reads / chat-client */ });
+ }
// Record this relay user's activity; their first message becomes a join date.
// Record this relay user's activity; their first message becomes a join date.
try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }
try { db.recordRelayUser(bridge.platform, username); } catch { /* non-critical */ }
@@ -496,14 +506,25 @@ class ChatRelayService {
@@ -496,14 +506,25 @@ class ChatRelayService {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
- /** Chat ingress: Chat persists, broadcasts, mirrors, reads aloud and records the first chat in one call. */
- _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {
+ /**
+ * Chat ingress: Chat persists, broadcasts, reads aloud and records the first chat in one call.
+ * The welcome decision asks Chat's first-chat read BEFORE the message send — Chat records the
+ * first chat on that call, so a decision after it would never see `first` — and never Live's
+ * own pre-retirement copy. A second line within Chat's short "first" cache window is deduped
+ * in-process; the cached answer is dropped after the send so the next check sees Chat's record.
+ */
+ async _deliverToChat(delivery, bridge, username, prefixedUsername, color, extras, message) {
let first = false;
let first = false;
+ let welcomeKey = null;
try {
try {
const stream = db.getStreamById(bridge.streamId);
const stream = db.getStreamById(bridge.streamId);
- first = !!(stream?.user_id && db.isFirstChatInChannel(`ext:${prefixedUsername}`, stream.user_id));
- } catch { /* non-critical */ }
- delivery.mirror('recordRelayUser', bridge.platform, username);
+ const chatterKey = `ext:${prefixedUsername}`;
+ if (stream?.user_id) {
+ welcomeKey = `${stream.user_id}:${chatterKey}`;
+ first = await chatReads.firstChat(stream.user_id, chatterKey);
+ if (first && welcomedRelayUsers.has(welcomeKey)) first = false;
+ }
+ } catch { /* non-critical — no welcome rather than a wrong one */ }
delivery.moderate('relay-record', { platform: bridge.platform, username });
delivery.moderate('relay-record', { platform: bridge.platform, username });
delivery.after(delivery.message({
delivery.after(delivery.message({
stream_id: bridge.streamId, username: prefixedUsername, message, message_type: 'chat', is_global: false,
stream_id: bridge.streamId, username: prefixedUsername, message, message_type: 'chat', is_global: false,
@@ -512,8 +533,9 @@ class ChatRelayService {
@@ -512,8 +533,9 @@ class ChatRelayService {
tts: { identity_key: `${bridge.platform}:${prefixedUsername}` },
tts: { identity_key: `${bridge.platform}:${prefixedUsername}` },
}), (id) => this._relayFollowUps(bridge, username, prefixedUsername, message, extras, id));
}), (id) => this._relayFollowUps(bridge, username, prefixedUsername, message, extras, id));
if (first) {
if (first) {
- // Chat records the first chat in its copy; Live's copy decides the next welcome.
- try { delivery.mirror('recordFirstChat', `ext:${prefixedUsername}`, db.getStreamById(bridge.streamId).user_id); } catch { /* */ }
+ if (welcomedRelayUsers.size >= WELCOMED_MAX) welcomedRelayUsers.clear();
+ welcomedRelayUsers.add(welcomeKey);
+ chatReads.invalidate(`fc:${welcomeKey}`); // Chat's cached `first` answer is about to be stale
delivery.event({ kind: 'stream', id: bridge.streamId }, {
delivery.event({ kind: 'stream', id: bridge.streamId }, {
type: 'system',
type: 'system',
message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
@@ -655,7 +655,8 @@ class RobotStreamerService {
@@ -655,7 +655,8 @@ class RobotStreamerService {
const username = `[RS] ${rawUsername}`;
const username = `[RS] ${rawUsername}`;
// Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached
// Check if this relay user is hidden/banned: Chat's queue in chat mode (a cached
- // peek), Live's own mirror-fed table otherwise; Chat unreachable falls back to it.
+ // peek — fresh list, else the last good one; a cold cache fails open), Live's own
+ // table only when Live is not in chat mode.
try {
try {
const channel = stream.channel_id
const channel = stream.channel_id
? db.getChannelById(stream.channel_id)
? db.getChannelById(stream.channel_id)
@@ -667,7 +668,7 @@ class RobotStreamerService {
@@ -667,7 +668,7 @@ class RobotStreamerService {
// Record this relay user (first message = join date) so RobotStreamer
// Record this relay user (first message = join date) so RobotStreamer
// chatters get the same chat logs + AI insight as other relay users.
// chatters get the same chat logs + AI insight as other relay users.
- if (delivery.ingress()) { delivery.mirror('recordRelayUser', 'rs', rawUsername); delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }
+ if (delivery.ingress()) { delivery.moderate('relay-record', { platform: 'rs', username: rawUsername }); }
else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }
else { try { db.recordRelayUser('rs', rawUsername); } catch { /* non-critical */ } }
// Let RobotStreamer viewers trigger channel !sound commands too. If the
// Let RobotStreamer viewers trigger channel !sound commands too. If the
@@ -677,8 +678,8 @@ class RobotStreamerService {
@@ -677,8 +678,8 @@ class RobotStreamerService {
if (trimmed.startsWith('!')) {
if (trimmed.startsWith('!')) {
const parts = trimmed.split(/\s+/);
const parts = trimmed.split(/\s+/);
const scmd = parts[0].slice(1).toLowerCase();
const scmd = parts[0].slice(1).toLowerCase();
- // Chat owns channel_sounds; ask its by-command read (a cached peek), which falls
- // back to Live's own table while Chat is unreachable.
+ // Chat owns channel_sounds; ask its by-command read (a cached peek). Live's own
+ // frozen table is never a fallback — null means no sound (or Chat unreachable).
if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {
if (scmd && chatReads.soundByCommandPeek(stream.user_id, scmd)) {
if (delivery.ingress()) {
if (delivery.ingress()) {
delivery.event({ kind: 'stream', id: stream.id }, {
delivery.event({ kind: 'stream', id: stream.id }, {
@@ -1368,7 +1368,7 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
@@ -1368,7 +1368,7 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
});
});
const _callUserRate = new Map(); // userId → [timestamps]
const _callUserRate = new Map(); // userId → [timestamps]
-router.post('/voice-channels/call-user', requireAuth, (req, res) => {
+router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
try {
try {
// Six calls a minute per account: a ring is a notification on someone else's screen.
// Six calls a minute per account: a ring is a notification on someone else's screen.
const now = Date.now();
const now = Date.now();
@@ -1383,7 +1383,15 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
@@ -1383,7 +1383,15 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
- try { const dm = require('../chat/dm'); if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }
+ // Blocks live in Chat (dm_blocks is Chat's table), so the check goes through chat-reads. On an
+ // unverifiable answer — a stale cache with Chat unreachable — the ring is REFUSED: ringing
+ // someone who blocked the caller is the worse failure, and 'try again shortly' is honest.
+ const blocked = await chatReads.dmBlocked(req.user.id, targetUser.id).catch(() => null);
+ if (blocked !== false) {
+ return blocked === true
+ ? res.status(403).json({ error: 'You cannot call this user' })
+ : res.status(503).json({ error: 'Could not check the block list — try again shortly' });
+ }
// Reuse caller's existing temp channel if present; otherwise create a private one — a
// Reuse caller's existing temp channel if present; otherwise create a private one — a
// 1:1 call is not something the whole site should see listed and be able to walk into.
// 1:1 call is not something the whole site should see listed and be able to walk into.
@@ -37,7 +37,6 @@ function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {
@@ -37,7 +37,6 @@ function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {
}
}
const READ = serviceToken(['live.chat_context.read']);
const READ = serviceToken(['live.chat_context.read']);
const WRITE = serviceToken(['live.chat_effects.write']);
const WRITE = serviceToken(['live.chat_effects.write']);
-const MIRROR = serviceToken(['live.chat_mirror.write']);
// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (its typed ingress).
// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (its typed ingress).
const ingressCalls = [];
const ingressCalls = [];
@@ -48,7 +47,7 @@ const network = http.createServer((req, res) => {
@@ -48,7 +47,7 @@ const network = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
if (req.url === '/oauth/token') {
if (req.url === '/oauth/token') {
const f = new URLSearchParams(raw);
const f = new URLSearchParams(raw);
- return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
+ return res.end(JSON.stringify({ access_token: serviceToken(['chat.message.send', 'chat.event.publish', 'chat.moderation.write', 'chat.cache.invalidate', 'chat.presence.read', 'chat.messages.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
}
}
res.statusCode = 404; res.end('{}');
res.statusCode = 404; res.end('{}');
});
});
@@ -56,14 +55,15 @@ const network = http.createServer((req, res) => {
@@ -56,14 +55,15 @@ const network = http.createServer((req, res) => {
// Chat's internal read API (roadmap T3 J4b): Live reads stats, queues, history and sounds through
// Chat's internal read API (roadmap T3 J4b): Live reads stats, queues, history and sounds through
// these. The stub answers from Live's own tables (emulating Chat's copy) so the seeds apply, and
// these. The stub answers from Live's own tables (emulating Chat's copy) so the seeds apply, and
// `readState.down` fails every read the way a Chat outage does.
// `readState.down` fails every read the way a Chat outage does.
-const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0 };
+const readState = { down: false, soundAssetDown: false, relayOnly: [], msgById: 0, firstChatCalls: 0, dmBlocked: false, blockStateDown: false };
function readReply(req, res, raw) {
function readReply(req, res, raw) {
const url = String(req.url);
const url = String(req.url);
const path = url.split('?')[0];
const path = url.split('?')[0];
const q = new URLSearchParams(url.split('?')[1] || '');
const q = new URLSearchParams(url.split('?')[1] || '');
const isRead = path === '/internal/chat/stats' || path === '/internal/chat/messages' || path === '/internal/chat/first-chat'
const isRead = path === '/internal/chat/stats' || path === '/internal/chat/messages' || path === '/internal/chat/first-chat'
|| path.startsWith('/internal/chat/moderation/') || path === '/internal/chat/sounds'
|| path.startsWith('/internal/chat/moderation/') || path === '/internal/chat/sounds'
- || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset';
+ || path === '/internal/chat/sounds/by-command' || path === '/internal/chat/sounds/asset'
+ || path === '/internal/chat/dm/block-state';
if (!isRead) return false;
if (!isRead) return false;
if (readState.down) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }
if (readState.down) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }
if (path === '/internal/chat/sounds/asset' && readState.soundAssetDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat sound write unavailable' })); return true; }
if (path === '/internal/chat/sounds/asset' && readState.soundAssetDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat sound write unavailable' })); return true; }
@@ -101,6 +101,10 @@ function readReply(req, res, raw) {
@@ -101,6 +101,10 @@ function readReply(req, res, raw) {
const r = d2.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters FROM chat_messages WHERE ${where.join(' AND ')}`, params);
const r = d2.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT ${CHATTER}) AS chatters FROM chat_messages WHERE ${where.join(' AND ')}`, params);
return ok({ messages: Number(r.messages), chatters: Number(r.chatters) });
return ok({ messages: Number(r.messages), chatters: Number(r.chatters) });
}
}
+ if (path === '/internal/chat/dm/block-state') {
+ if (readState.blockStateDown) { res.statusCode = 503; res.end(JSON.stringify({ ok: false, error: 'Chat read unavailable' })); return true; }
+ return ok({ blocked: !!readState.dmBlocked });
+ }
if (path === '/internal/chat/first-chat') {
if (path === '/internal/chat/first-chat') {
readState.firstChatCalls++;
readState.firstChatCalls++;
const r = d2.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
const r = d2.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
@@ -211,6 +215,11 @@ const chat = http.createServer((req, res) => {
@@ -211,6 +215,11 @@ const chat = http.createServer((req, res) => {
const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const waitFor = async (pred, what, ms = 3000) => {
+ const end = Date.now() + ms;
+ while (Date.now() < end) { const v = pred(); if (v) return v; await sleep(10); }
+ throw new Error(`timed out waiting for ${what}`);
+};
(async () => {
(async () => {
process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
@@ -268,6 +277,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -268,6 +277,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
app.use('/internal/chat-context', routes.contextRouter);
app.use('/internal/chat-context', routes.contextRouter);
app.use('/internal/chat-effects', routes.effectsRouter);
app.use('/internal/chat-effects', routes.effectsRouter);
app.use('/api/mod', require('../server/admin/mod-routes'));
app.use('/api/mod', require('../server/admin/mod-routes'));
+ app.use('/api/streams', require('../server/streaming/routes'));
const port = await listen(http.createServer(app));
const port = await listen(http.createServer(app));
const call = async (method, p, { token, body, headers = {} } = {}) => {
const call = async (method, p, { token, body, headers = {} } = {}) => {
const res = await fetch(`http://127.0.0.1:${port}${p}`, {
const res = await fetch(`http://127.0.0.1:${port}${p}`, {
@@ -400,25 +410,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -400,25 +410,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
[viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);
[viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);
assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));
assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));
- // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept. Chat's
- // LIVE_MIRROR is on in production, so the tables it keeps current stay Live's until the
- // later step that removes both the mirror and its remaining readers.
- const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
- assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
- let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);
- assert.strictEqual(m.body.applied, 1);
- assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');
- // Columns only Live's copy has (media-proxy/asset-sync adds them at start).
- for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }
- d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);
- m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);
- assert.strictEqual(m.body.applied, 2);
- assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');
- const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();
- assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });
- assert.strictEqual(db.getChatMessageById(900001), undefined);
- assert.ok(db.getUserById(viewer), 'users are never touched');
-
// 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its
// 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its
// outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).
// outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).
const chatDelivery = require('../server/chat/chat-delivery');
const chatDelivery = require('../server/chat/chat-delivery');
@@ -483,8 +474,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -483,8 +474,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.ok(chatReads.siteStatsPeek(), 'the background warm-up answered');
assert.ok(chatReads.siteStatsPeek(), 'the background warm-up answered');
// (2)(4) A TTS override survives a TTL and a Chat outage (the engine reads it
// (2)(4) A TTS override survives a TTL and a Chat outage (the engine reads it
- // synchronously); a cold peek answers Live's own mirror table, not the auto voice.
- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a cold peek falls back to Live\'s own table');
+ // synchronously); in chat mode a cold peek answers null, never Live's frozen copy.
+ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a cold peek answers null, never Live\'s frozen table');
for (let i = 0; i < 300 && !chatReads.ttsOverridePeek('user:viewer'); i++) await sleep(10);
for (let i = 0; i < 300 && !chatReads.ttsOverridePeek('user:viewer'); i++) await sleep(10);
assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3');
assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3');
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'ttsp:');
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'ttsp:');
@@ -530,17 +521,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -530,17 +521,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cs = await chatReads.channelSamples(streamer, { relay: { platform: 'twitch', rawUsername: 'alice' }, limit: 10 });
const cs = await chatReads.channelSamples(streamer, { relay: { platform: 'twitch', rawUsername: 'alice' }, limit: 10 });
assert.strictEqual(cs.length, 1, 'a relay clone sample finds the prefixed row');
assert.strictEqual(cs.length, 1, 'a relay clone sample finds the prefixed row');
- // (1) Hidden relay users: Live's mirror row hides at once; Chat's queue hides even when
- // the mirror has not seen it; a Chat outage fails open to Live's own table.
- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, 'the mirror row hides at once');
+ // (1) Hidden relay users come from Chat's list only in chat mode: a cold cache fails
+ // open and warms in the background, and a Chat outage answers the last good list.
+ chatReads._reset();
+ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), false, 'a cold cache fails open');
readState.relayOnly = [{ id: 999999, channel_id: channel.id, platform: 'youtube', external_username: 'ghost' }];
readState.relayOnly = [{ id: 999999, channel_id: channel.id, platform: 'youtube', external_username: 'ghost' }];
assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, 'the first check warms Chat in the background');
assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, 'the first check warms Chat in the background');
for (let i = 0; i < 300 && !chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'); i++) await sleep(10);
for (let i = 0; i < 300 && !chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'); i++) await sleep(10);
- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user Live's mirror has not seen");
+ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, "Chat's queue hides a user");
+ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Chat's copy carries alice (the stub reads the same table)");
readState.down = true;
readState.down = true;
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'hru:');
chatReads._age(chatReads.CACHE_TTL_MS + 1000, 'hru:');
- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), false, "Chat unreachable fails open to Live's own table");
- assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), true, "Live's own hidden row still applies");
+ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'youtube', 'ghost'), true, 'Chat unreachable answers the last good list');
+ assert.strictEqual(chatReads.isRelayUserHidden(channel.id, 'twitch', 'nobody'), false, 'a name no list carries stays unhidden');
readState.down = false;
readState.down = false;
readState.relayOnly = [];
readState.relayOnly = [];
chatReads._reset();
chatReads._reset();
@@ -627,9 +620,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -627,9 +620,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatReads._reset();
chatReads._reset();
}
}
- // (4) A cold or outage peek answers Live's own override table, not the auto voice.
+ // (4) In chat mode a cold or outage peek answers null, never Live's frozen override table.
+ chatReads._reset();
readState.down = true;
readState.down = true;
- assert.strictEqual(chatReads.ttsOverridePeek('user:viewer').voice, 'en+f3', 'a peek falls back to Live\'s own table');
+ assert.strictEqual(chatReads.ttsOverridePeek('user:viewer'), null, 'a peek answers null, never Live\'s frozen table');
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
@@ -741,8 +735,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -741,8 +735,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatReads._reset();
chatReads._reset();
}
}
- // (3) Live's own hidden row OR Chat's fresh list — a local hide the mirror applied still
- // hides even when a fresh Chat list lacks it (the #28 rule, restored).
+ // (3) Live's own hidden copy is frozen and dead weight in chat mode: a stale local hide
+ // that Chat's list does not carry must not hide anyone (the mirror no longer keeps it in step).
{
{
const chatClient = require('../server/chat/chat-client');
const chatClient = require('../server/chat/chat-client');
const realReadRelayUsers = chatClient.readRelayUsers;
const realReadRelayUsers = chatClient.readRelayUsers;
@@ -752,11 +746,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -752,11 +746,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothing
chatClient.readRelayUsers = async () => ({ relay_users: [] }); // Chat's list holds nothing
chatReads._reset();
chatReads._reset();
try {
try {
- d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);
- chatReads.isRelayUserHidden(freshCh, 'twitch', 'nobody'); // warm Chat's fresh (empty) list
- for (let i = 0; i < 100; i++) await sleep(10);
+ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'no local row yet');
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);
d2.run("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'localonly', 'hide', ?)", [freshCh, streamer]);
- assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own hidden row wins over a fresh Chat list that lacks it");
+ assert.strictEqual(d2.isRelayUserHidden(freshCh, 'twitch', 'localonly'), true, "Live's own row is there");
+ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, 'a cold cache fails open, never Live\'s row');
+ for (let i = 0; i < 300 && chatReads._size() === 0; i++) await sleep(10);
+ assert.ok(chatReads._size() > 0, 'the background read warmed the cache');
+ assert.strictEqual(chatReads.isRelayUserHidden(freshCh, 'twitch', 'localonly'), false, "Live's stale local hide is ignored — Chat's list is the truth");
} finally {
} finally {
chatClient.readRelayUsers = realReadRelayUsers;
chatClient.readRelayUsers = realReadRelayUsers;
d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);
d2.run('DELETE FROM hidden_relay_users WHERE channel_id = ?', [freshCh]);
@@ -869,50 +865,168 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -869,50 +865,168 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await chatReads.firstChat(streamer, `user:${viewer}`);
await chatReads.firstChat(streamer, `user:${viewer}`);
assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');
assert.strictEqual(readState.firstChatCalls, 1, 'a true answer is re-asked after the short TTL');
chatReads._reset();
chatReads._reset();
- d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);
+ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
+ assert.strictEqual(d2.isFirstChatInChannel(`user:${viewer}`, streamer), true, 'Live\'s frozen table would say first');
readState.down = true;
readState.down = true;
- assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek falls to Live\'s own table');
+ assert.strictEqual(chatReads.firstChatPeek(streamer, `user:${viewer}`), false, 'a cold + down peek answers not-first, never Live\'s frozen table');
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
// The AI context passes `user:<user_id>`, never the username: a row keyed by the numeric id
// The AI context passes `user:<user_id>`, never the username: a row keyed by the numeric id
// suppresses the welcome flag, where the old `user:<username>` key would have missed it.
// suppresses the welcome flag, where the old `user:<username>` key would have missed it.
+ // Its peek is synchronous, so each case warms Chat's answer first.
d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, channel_user_id, timestamp) VALUES (?, 'viewer', 'hi there', 'chat', ?, ?)", [viewer, streamer, sqlNow()]);
d2.run("INSERT INTO chat_messages (user_id, username, message, message_type, channel_user_id, timestamp) VALUES (?, 'viewer', 'hi there', 'chat', ?, ?)", [viewer, streamer, sqlNow()]);
await chatReads.channelMessages(streamer, 40);
await chatReads.channelMessages(streamer, 40);
const context = require('../server/ai/context');
const context = require('../server/ai/context');
const ctxStream = db.getStreamById(streamId);
const ctxStream = db.getStreamById(streamId);
const greet = { remember_viewers: true, greet_first_timers: true, max_open_threads: 3, hear_enabled: false };
const greet = { remember_viewers: true, greet_first_timers: true, max_open_threads: 3, hear_enabled: false };
+ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', [`user:${viewer}`, streamer]);
chatReads.invalidate('fc:');
chatReads.invalidate('fc:');
+ await chatReads.firstChat(streamer, `user:${viewer}`); // warm: the context's peek cannot await
let tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
let tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
assert.ok(!/first time chatting here/.test(tail.text), 'a user with a recorded first chat is not greeted again');
assert.ok(!/first time chatting here/.test(tail.text), 'a user with a recorded first chat is not greeted again');
d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', [`user:${viewer}`]);
chatReads.invalidate('fc:');
chatReads.invalidate('fc:');
+ await chatReads.firstChat(streamer, `user:${viewer}`);
tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
tail = context.volatileTail({ userId: streamer, stream: ctxStream, settings: greet, sinceChatId: 0, botNames: new Set() });
assert.ok(/first time chatting here/.test(tail.text), 'a new user is flagged first time (user:<id> identity)');
assert.ok(/first time chatting here/.test(tail.text), 'a new user is flagged first time (user:<id> identity)');
chatReads._reset();
chatReads._reset();
- // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek falls
- // to Live's own table. ──
+ // ── Sounds by command: Chat owns the row; a 404 is authoritative; a cold/down peek
+ // answers null, never Live's own frozen table. ──
d2.run('DELETE FROM channel_sounds WHERE channel_owner_id = ?', [streamer]);
d2.run('DELETE FROM channel_sounds WHERE channel_owner_id = ?', [streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/h.mp3', ?)", [streamer, streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'honk', '/sounds/h.mp3', ?)", [streamer, streamer]);
chatReads._reset();
chatReads._reset();
+ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold peek answers null, never Live\'s frozen table');
+ for (let i = 0; i < 300 && !chatReads.soundByCommandPeek(streamer, 'honk'); i++) await sleep(10);
const honk = chatReads.soundByCommandPeek(streamer, 'honk');
const honk = chatReads.soundByCommandPeek(streamer, 'honk');
- assert.ok(honk && honk.command === 'honk', 'Chat answers the sound by command');
+ assert.ok(honk && honk.command === 'honk', 'the warmed peek answers Chat\'s sound by command');
assert.strictEqual(chatReads.soundByCommandPeek(streamer, '!HONK').command, 'honk', 'the command is normalized');
assert.strictEqual(chatReads.soundByCommandPeek(streamer, '!HONK').command, 'honk', 'the command is normalized');
assert.strictEqual(await chatReads.soundByCommand(streamer, 'nope'), null, 'Chat: no such sound');
assert.strictEqual(await chatReads.soundByCommand(streamer, 'nope'), null, 'Chat: no such sound');
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'nope', '/sounds/n.mp3', ?)", [streamer, streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'nope', '/sounds/n.mp3', ?)", [streamer, streamer]);
assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'nope'), null, 'a Chat 404 is cached: Live\'s own row is not used');
assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'nope'), null, 'a Chat 404 is cached: Live\'s own row is not used');
chatReads._reset();
chatReads._reset();
readState.down = true;
readState.down = true;
- assert.ok(chatReads.soundByCommandPeek(streamer, 'honk'), 'a cold + down peek falls back to Live\'s own table');
+ assert.strictEqual(chatReads.soundByCommandPeek(streamer, 'honk'), null, 'a cold + down peek answers null, never Live\'s frozen table');
readState.down = false;
readState.down = false;
chatReads._reset();
chatReads._reset();
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'bare404', '/sounds/b.mp3', ?)", [streamer, streamer]);
d2.run("INSERT INTO channel_sounds (channel_owner_id, command, url, created_by) VALUES (?, 'bare404', '/sounds/b.mp3', ?)", [streamer, streamer]);
const bare = await chatReads.soundByCommand(streamer, 'bare404');
const bare = await chatReads.soundByCommand(streamer, 'bare404');
- assert.ok(bare && bare.command === 'bare404', 'a 404 without Chat\'s own body is not "no such sound": Live\'s table answers');
+ assert.strictEqual(bare, null, 'a 404 without Chat\'s own body is not "no such sound": null, never Live\'s table');
chatReads._reset();
chatReads._reset();
}
}
+ // 11c. The mirror's retirement, checked where it would bite: a stale local row must not
+ // answer a chat-mode read, the relay welcome asks Chat's first-chat, an asset removal uses
+ // the Media id Chat sent, and a call invite fails closed when the block state is unknown.
+ {
+ const chatReads = require('../server/chat/chat-reads');
+ const chatClient = require('../server/chat/chat-client');
+ const d2 = require('../server/db/database');
+
+ // (1) ttsOverridePeek ignores Live's stale override: Chat says "none".
+ {
+ const realReadTts = chatClient.readTtsOverride;
+ chatClient.readTtsOverride = async () => ({ tts_override: null });
+ chatReads._reset();
+ d2.run("INSERT OR REPLACE INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by) VALUES ('user:stale', 'en+m7', 99, 200, 0, ?)", [admin]);
+ try {
+ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, "Chat's 'no override' wins over Live's stale row");
+ assert.strictEqual(chatReads.ttsOverridePeek('user:stale'), null, 'and stays null from the cache');
+ } finally {
+ chatClient.readTtsOverride = realReadTts;
+ d2.run("DELETE FROM tts_voice_overrides WHERE identity_key = 'user:stale'");
+ chatReads._reset();
+ }
+ }
+
+ // (2) The relay welcome asks Chat's first-chat read before the message send; Live's own
+ // frozen stream_first_chats neither welcomes nor suppresses.
+ {
+ const relay = require('../server/integrations/chat-relay-service');
+ const realReadFirstChat = chatClient.readFirstChat;
+ const realFollowUps = relay._relayFollowUps;
+ const welcomes = (name) => ingressCalls.filter((c) => c.family === 'events' && c.body.frame && c.body.frame.type === 'system' && new RegExp(`Welcome ${name}\\b`).test(String(c.body.frame.message)));
+ const relayed = (name) => ingressCalls.filter((c) => c.family === 'messages' && c.body.username === `[Twitch] ${name}`);
+ relay._relayFollowUps = () => {}; // the welcome decision is what this checks
+ chatReads._reset();
+ try {
+ // Chat: not first, and Live's copy has no row either — nothing to welcome.
+ d2.run('DELETE FROM stream_first_chats WHERE chatter_key = ?', ['ext:[Twitch] carol']);
+ chatClient.readFirstChat = async () => ({ first: false });
+ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'carol', 'hello from twitch', {});
+ await waitFor(() => relayed('carol').length, 'the relayed line');
+ await sleep(50);
+ assert.strictEqual(welcomes('carol').length, 0, "Chat's not-first answer suppresses the welcome");
+
+ // Chat: first, even though Live's frozen copy has a row (it used to suppress it).
+ d2.run('INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)', ['ext:[Twitch] dave', streamer]);
+ chatClient.readFirstChat = async () => ({ first: true });
+ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'first line', {});
+ await waitFor(() => welcomes('dave').length, "dave's welcome");
+ // A second line within Chat's short "first" cache window is not welcomed twice.
+ await relay._broadcastMessage({ platform: 'twitch', streamId }, 'dave', 'second line', {});
+ await waitFor(() => relayed('dave').length >= 2, 'the second relayed line');
+ await sleep(80);
+ assert.strictEqual(welcomes('dave').length, 1, "Chat's first-chat answer welcomes once, deduped in-process");
+ } finally {
+ chatClient.readFirstChat = realReadFirstChat;
+ relay._relayFollowUps = realFollowUps;
+ d2.run("DELETE FROM stream_first_chats WHERE chatter_key IN ('ext:[Twitch] carol', 'ext:[Twitch] dave')");
+ chatReads._reset();
+ }
+ }
+
+ // (3) /asset-sync remove-sound deletes the Media asset id Chat sent, never the one on
+ // Live's frozen channel_sounds row.
+ {
+ const media = require('../server/media-client');
+ const realRequest = media.request;
+ const deleted = [];
+ media.request = async (method, p) => { if (method === 'DELETE') deleted.push(p); return { ok: true }; };
+ try {
+ d2.run("INSERT OR REPLACE INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_asset_id) VALUES (777001, ?, 'given', '/sounds/given.mp3', ?, 424242)", [streamer, streamer]);
+ const out = await call('POST', '/internal/chat-effects/asset-sync', { token: WRITE, body: { op: 'remove-sound', asset_id: 555111 } });
+ assert.strictEqual(out.status, 200);
+ await sleep(20);
+ } finally {
+ media.request = realRequest;
+ d2.run('DELETE FROM channel_sounds WHERE id = 777001');
+ }
+ assert.deepStrictEqual(deleted, ['/assets/555111'], 'the Media asset id in the request is the one deleted');
+ }
+
+ // (4) The call invite asks Chat for the block state: blocked refuses, an unknown state
+ // (Chat down past the cache) refuses too, never rings through.
+ {
+ const callerToken = jwt.sign({ sub: String(streamer), username: 'streamer', role: 'streamer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 300 });
+ const callUser = (body) => call('POST', '/api/streams/voice-channels/call-user', { token: callerToken, body });
+ chatReads._reset();
+ readState.dmBlocked = true;
+ try {
+ const blocked = await callUser({ user_id: viewer });
+ assert.strictEqual(blocked.status, 403, 'a blocked pair cannot ring');
+ assert.match(blocked.body.error, /cannot call this user/i);
+
+ readState.dmBlocked = false;
+ chatReads._reset();
+ const allowed = await callUser({ user_id: viewer });
+ assert.strictEqual(allowed.status, 200, `an unblocked pair rings (${allowed.text})`);
+ assert.strictEqual(allowed.body.invited, true);
+
+ readState.down = true;
+ chatReads._reset();
+ const unknown = await callUser({ user_id: viewer });
+ assert.strictEqual(unknown.status, 503, 'an unverifiable block state refuses the ring');
+ assert.match(unknown.body.error, /try again shortly/i);
+ } finally {
+ readState.dmBlocked = false;
+ readState.down = false;
+ chatReads._reset();
+ }
+ }
+ }
+
// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
// 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
{
{
const sent = [];
const sent = [];
@@ -45,11 +45,13 @@ const network = http.createServer((req, res) => {
@@ -45,11 +45,13 @@ const network = http.createServer((req, res) => {
});
});
});
});
-// Stub Chat: records every ingress call; `fail` answers chosen families with a status.
+// Stub Chat: records every ingress call; `fail` answers chosen families with a status. Its first-chat
+// and hidden-relay-user reads answer from Live's own tables (emulating Chat's copy).
const calls = [];
const calls = [];
const bridgeCalls = [];
const bridgeCalls = [];
const fail = {};
const fail = {};
let nextId = 5000;
let nextId = 5000;
+let firstChatReads = 0;
const chat = http.createServer((req, res) => {
const chat = http.createServer((req, res) => {
let raw = '';
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('data', (c) => { raw += c; });
@@ -60,6 +62,18 @@ const chat = http.createServer((req, res) => {
@@ -60,6 +62,18 @@ const chat = http.createServer((req, res) => {
if (req.url === '/internal/live/calls') bridgeCalls.push(JSON.parse(raw));
if (req.url === '/internal/live/calls') bridgeCalls.push(JSON.parse(raw));
return res.end(JSON.stringify({ ok: true, results: [] }));
return res.end(JSON.stringify({ ok: true, results: [] }));
}
}
+ if (String(req.url).startsWith('/internal/chat/first-chat')) {
+ firstChatReads++;
+ const q = new URL(req.url, 'http://x').searchParams;
+ const d3 = require('../server/db/database');
+ const row = d3.get('SELECT 1 AS present FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?', [String(q.get('identity')), Number(q.get('channel_id'))]);
+ return res.end(JSON.stringify({ ok: true, first: !row }));
+ }
+ if (String(req.url).startsWith('/internal/chat/moderation/relay-users')) {
+ const q = new URL(req.url, 'http://x').searchParams;
+ const rows = require('../server/db/database').all('SELECT * FROM hidden_relay_users WHERE channel_id = ? OR channel_id IS NULL LIMIT ?', [Number(q.get('channel_id')), Number(q.get('limit') || 100)]);
+ return res.end(JSON.stringify({ ok: true, relay_users: rows }));
+ }
const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);
const m = req.url.match(/^\/internal\/chat\/(messages|events|moderation|invalidate|presence)(?:\?|$)/);
if (!m) { res.statusCode = 404; return res.end('{}'); }
if (!m) { res.statusCode = 404; return res.end('{}'); }
const family = m[1];
const family = m[1];
@@ -151,17 +165,27 @@ function assertSigned(c, what) {
@@ -151,17 +165,27 @@ function assertSigned(c, what) {
assert.deepStrictEqual([rl.body.username, rl.body.frame.role, rl.body.mirror, rl.body.tts.identity_key], ['[Twitch] alice', 'external', true, 'twitch:[Twitch] alice']);
assert.deepStrictEqual([rl.body.username, rl.body.frame.role, rl.body.mirror, rl.body.tts.identity_key], ['[Twitch] alice', 'external', true, 'twitch:[Twitch] alice']);
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'relay-record' && b.username === 'alice'), 'relay record'), 'relay record');
assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');
assertSigned(await waitFor(() => find('events', (b) => b.frame.type === 'system' && /Welcome alice/.test(b.frame.message)), 'welcome'), 'welcome');
- assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live keeps its first-chat copy');
+ assert.ok(firstChatReads >= 1, "the welcome decision asked Chat's first-chat read");
+ assert.ok(!d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = '[Twitch] alice' OR chatter_key = 'ext:[Twitch] alice'").get(), 'Live writes no first-chat copy of its own in chat mode');
+ const welcomeCount = () => calls.filter((c) => c.family === 'events' && c.body.frame && /Welcome alice/.test(String(c.body.frame.message || ''))).length;
+ relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'second line', {});
+ await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'second line'), 'the second relay line');
+ await sleep(80);
+ assert.strictEqual(welcomeCount(), 1, 'a second line within the first-chat cache window is not welcomed twice');
// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.
// 3b. A hidden relay user is dropped before any call to Chat; unhiding resumes relaying.
const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;
const relayLines = (message) => calls.filter((c) => c.family === 'messages' && c.body.username === '[Twitch] alice' && (!message || c.body.message === message)).length;
+ const chatReads = require('../server/chat/chat-reads');
d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);
d.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, created_by) VALUES (?, 'twitch', 'alice', 'hide', ?)").run(channel.id, admin);
+ chatReads.invalidate('hru:'); // the mod route's hide drops the cached list; the test writes the row directly
+ await waitFor(() => chatReads.isRelayUserHidden(channel.id, 'twitch', 'alice'), "Chat's hidden list to warm");
const beforeHidden = relayLines();
const beforeHidden = relayLines();
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'should not relay', {});
await sleep(80);
await sleep(80);
assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');
assert.strictEqual(relayLines(), beforeHidden, 'a hidden relay user is dropped before Chat');
assert.strictEqual(relayLines('should not relay'), 0);
assert.strictEqual(relayLines('should not relay'), 0);
d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();
d.prepare("DELETE FROM hidden_relay_users WHERE platform = 'twitch' AND external_username = 'alice'").run();
+ chatReads.invalidate('hru:'); // as the unhide route does
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});
relay._broadcastMessage({ platform: 'twitch', streamId }, 'alice', 'relayed again', {});
assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');
assertSigned(await waitFor(() => find('messages', (b) => b.source_platform === 'twitch' && b.message === 'relayed again'), 'the unhidden relay line'), 'relay line after unhide');
@@ -194,7 +218,7 @@ function assertSigned(c, what) {
@@ -194,7 +218,7 @@ function assertSigned(c, what) {
const del = find('moderation', (b) => b.action === 'delete-message');
const del = find('moderation', (b) => b.action === 'delete-message');
assertSigned(del, 'message delete');
assertSigned(del, 'message delete');
assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);
assert.deepStrictEqual([del.body.id, del.body.deleted_by, del.body.key], [900010, admin, 'live:moderation:delete:900010']);
- assert.strictEqual(db.getChatMessageById(900010).is_deleted, 1, "Live's copy is deleted too (the read mirror is on in production)");
+ assert.strictEqual(db.getChatMessageById(900010).is_deleted, 0, "Live's own copy is untouched: Chat owns the delete (the read mirror is retired)");
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');
assertSigned(await waitFor(() => find('moderation', (b) => b.action === 'log' && b.action_type === 'message_delete'), 'delete log'), 'delete log');
const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });
const ban = await mod(`/users/${viewer}/ban`, { reason: 'spam' });
assert.ok(ban.status < 300, `ban answered ${ban.status}`);
assert.ok(ban.status < 300, `ban answered ${ban.status}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+// T3: the Live end of the retired chat read mirror is gone. OpenVibe.Chat #25 (deployed) removed the
+// sender (its LIVE_MIRROR writer and the capture triggers); this change removed Live's receiver —
+// POST /internal/chat-effects/mirror and the chat-delivery.js mirror() helper (MIRROR_WRITES) — and
+// its call sites. Static guard: nothing under server/ names the receiver, its capability, the mirror
+// tables or the helper. Live's own chat tables stay for now (a later change drops them).
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+
+const PATTERNS = [
+ ['chat-effects/mirror', /chat-effects\/mirror/],
+ ['live.chat_mirror.write', /live\.chat_mirror\.write/],
+ ['MIRROR_TABLES', /\bMIRROR_TABLES\b/],
+ ['applyMirror', /\bapplyMirror\b/],
+ ['liveColumns', /\bliveColumns\b/],
+ ['MIRROR_WRITES', /\bMIRROR_WRITES\b/],
+ ['delivery.mirror', /delivery\.mirror\b/],
+];
+
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { walk(p); continue; }
+ if (!p.endsWith('.js')) continue;
+ const rel = path.relative(ROOT, p);
+ const src = fs.readFileSync(p, 'utf8');
+ for (const [name, re] of PATTERNS) if (re.test(src)) offenders.push(`${rel}: ${name}`);
+ }
+})(path.join(ROOT, 'server'));
+
+assert.deepStrictEqual(offenders, [], `server/ still references the retired chat read mirror:\n${offenders.join('\n')}`);
+
+console.log('chat mirror removed: nothing under server/ references chat-effects/mirror, live.chat_mirror.write, MIRROR_TABLES/applyMirror/liveColumns/MIRROR_WRITES or delivery.mirror — all checks passed');