openvibers/openvibe.chat Commits
Parity gaps closed (roadmap WS-I task 6): sub-only mode, blocks in public chat, slow mode as the saved setting, purges and deletes on every surface, /clear says what it does
Changes
@@@ -23,7 +23,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, everymessage type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,-- `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /paste /ai`, `!sr !queue++ `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /subonly /paste /ai`, `!sr !queue!np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word@@@ -4,7 +4,7 @@"owner": "live","status": "planned","visibility": "internal",-- "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.",++ "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, active channel subscriptions (sub-only chat), IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.","permissions": ["live:read"],@@@ -18,6 +18,7 @@"GET /internal/chat-context/users","POST /internal/chat-context/users/lookup","GET /internal/chat-context/users/:id/follows",++ "GET /internal/chat-context/subscriber","GET /internal/chat-context/users/profile","GET /internal/chat-context/streams","GET /internal/chat-context/streams/active",@@@ -15,12 +15,12 @@ bridge and the CLI's own safety checks.| `soundboard` | `!<sound>` gives the room the "played !sound" line and the `soundboard-audio` frame, through the queue | write || `ban` | `/ban`: everyone sees the notice, the banned person's next line is refused and reaches nobody, `/unban` lifts it | destructive || `timeout` | `/timeout B 2`: refused inside the two seconds, talking again after | destructive |--| `purge` | the dashboard's range purge (ISO `from`/`to`): the room gets the `purge` frame, the range is gone from history, the line before it stays | destructive |--| `slow` | `/slow 2`: a viewer's second line inside the window is refused and reaches nobody; the moderator's two lines 1.2 s apart both go out | destructive |--| `subonly` | **gap**: there is no sub-only mode (below) | — |++| `purge` | the dashboard's range purge (ISO `from`/`to`): the stream's sockets get the `purge` frame, a channel-room popout and the global feed drop the same ids (`delete-messages`), channel and global cursor reads name them in `deleted_ids`; the range is gone from history, the line before it stays | destructive |++| `slow` | `/slow 2`: a new socket reads the saved value back; a viewer's second line inside the window is refused and reaches nobody; the moderator's two lines 1.2 s apart both go out | destructive |++| `subonly` | `/subonly`: the room gets the `subonly` frame and a new socket's `auth` says `sub_only`; B (no subscription) and an anonymous viewer are refused and reach nobody; A (an active subscriber) and MOD talk; after `/subonly off` B talks again | destructive || `popout` | a live popout (stream join) and a channel-room popout (join by `channelUserId`) read their histories, get the stream's lines, and the channel popout's line reaches the stream; `get-users` answers | write || `reconnect` | A stays, B leaves; a line is deleted and another written while B is away; B reconnects and reads `?after_id=<cursor>`: B's view equals A's, and a fresh page agrees | destructive |--| `blocked` | **gap**: public chat does not apply blocks (below) | — |++| `blocked` | A blocked B on the network (one-way): B's lines never reach A's stream socket or global feed; A reconnects and reads `?after_id=` as A: B's lines are left out and the cursor still moves past them; fresh pages read as A (channel, stream, global) leave them out, read as MOD they are there; A's lines reach B; after the unblock B reaches A again | destructive |## Running it@@@ -31,6 +31,7 @@ npm test -- parity # the scenarios against Chat with stubsOV_PARITY_BASE=http://127.0.0.1:4401 OV_PARITY_STREAM=<live stream of a test channel> \OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… OV_PARITY_TOKEN_STREAMER=… \OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer OV_PARITY_SOUND=honk \++OV_PARITY_SUBSCRIBER=a OV_PARITY_A_BLOCKS_B=1 \node scripts/parity.js [--apply] [--only join,send]```@@@ -38,6 +39,9 @@ Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MOstream's channel, STREAMER owns it (needed for `purge` only). `OV_PARITY_SOUND` names a channel soundcommand (else `soundboard` is skipped); `OV_PARITY_ORIGIN` (default `https://openvibe.live`) is theWebSocket origin; `OV_PARITY_CHANNEL_USER_ID` saves looking up the owner's id.++`OV_PARITY_SUBSCRIBER=a` says A holds an active Live subscription to the test channel and B none++(else `subonly` is skipped); `OV_PARITY_A_BLOCKS_B=1` says A has blocked B on openvibe.network (else++`blocked` is skipped; the test sets the block itself). Both leave the account in that state.**Safety.** With `--apply` the script first signs each token in and reads the stream's channelowner, and runs **nothing** (exit 2) unless every one of those accounts is named in@@@ -45,25 +49,46 @@ owner, and runs **nothing** (exit 2) unless every one of those accounts is namedtest accounts and their own channel. Do not run it against a real channel. Tokens are never printed.Exit 1 when a scenario fails; gaps and skips are reported, not failed.--## Known gaps (2026-09-25)++## Known gaps (2026-09-26)--- **Sub-only mode does not exist**, in Chat or in Live: no channel setting, no command, no check-- (followers-only exists). Chat also has no subscriber lookup: subscriptions are Live's. A product-- decision (what "sub" means: Live subscriptions, VIP membership), then a Live context read and a-- rule in `_chatRulesBlock`.--- **Blocks do not apply to public chat.** `network.block.changed` and `dm_blocks` are honoured for-- DMs and calls only. A blocked person's lines reach the blocker live and in every history read,-- before and after a reconnect, and Live's `chat.js` hides nothing either. Needs a decision (hide-- for the blocker only? both ways? not for moderators?), then per-recipient filtering in the-- broadcasts and the history reads.- **A channel-room popout's users panel is not the channel's.** `getUserList(null)` is everyoneoutside a stream: global chat and every offline channel room. The `popout` scenario notes it.--- **Slow mode lives in memory.** `/slow` is written to the channel's `slow_mode_seconds`, but that-- setting is never read back: a Chat restart turns slow mode off, and the dashboard's value is not-- enforced.--- `/clear` only clears screens; the lines stay in history and return on a reload. A range purge-- reaches only sockets joined to that stream (not a channel-room popout, not the global feed that-- showed the forwarded copies).++++## Closed (2026-09-26)++++The gaps the script found on 2026-09-25, with the product decisions taken for them:++++- **Sub-only mode.** A channel setting (`channel_moderation_settings.sub_only`, in Live and Chat),++ set by `/subonly` and `/subonly off` (the streamer, channel moderators and chat staff) and by the++ dashboard's moderation settings, written where that table's authority says (Live's++ `channel-settings` effect while it is at `live`, Chat's `upsertChannelModerationSettings` once Chat++ writes it). When it is on, only people with an **active Live channel subscription** to the streamer++ (Live's `subscriptions`: status active, the paid period not over; Billing's entitlement under++ `BILLING_AUTHORITY=billing`), the streamer, channel moderators and chat staff may chat; Network VIP++ does not count and anonymous viewers cannot chat. Chat asks Live `GET++ /internal/chat-context/subscriber` (`live-context.js` `isSubscriber`, cached a minute, asked again++ before a line when unknown or when a "no" is five seconds old) and **fails closed**: when Live++ cannot be asked, a non-moderator is told sub-only is on. The rule is in `_chatRulesBlock` (chat++ lines, `/me`, `/tts`); `!` commands follow the same rules as under followers-only (not gated).++- **Blocks in public chat**, one-way: someone who blocked a person on the network++ (`network.block.changed`, `network-blocks.js`) no longer gets that person's lines (chat, `/me`,++ `/tts` and its audio): every broadcast skips their sockets, and history pages and cursor reads++ (global, channel, stream; and openvibe.chat's first page) leave them out for that reader, with++ `latest_id` and `deleted_ids` unchanged so the cursor moves on. Everyone else, the blocked person++ included, is unaffected; moderation logs and exports show everything; DMs and calls keep their rule++ (blocked either way). Chat's own messenger blocks (`dm_blocks`) stay DM-only.++- **Slow mode is the saved `slow_mode_seconds`.** `/slow N` and `/slow off` write it (as above) and++ the room hears it once saved; Chat reads it back from the channel's policy (at join, after its own++ write, and when Live says the channel changed), so a restart keeps slow mode and the dashboard's++ value is enforced; a change made in the dashboard is announced to the room. Live's dashboard sent++ and read `slowmode_seconds`, which no writer knew, so its slow mode was never saved; it uses++ `slow_mode_seconds` now (the old name is still accepted). Moderators stay exempt.++- **Purges and deletes reach every surface.** A range purge sends the `purge` frame to the stream's++ sockets and the purged ids (`delete-messages`) to the whole channel room (other slots, the offline++ room, a channel popout) and the global feed; single deletes (Chat's own and Live's dashboard) reach++ the channel room and the global feed too. Cursor reads name them in `deleted_ids`.++- **`/clear` clears screens** (Twitch semantics) and now says so to the moderator: "Chat cleared on++ screen; messages stay in history — use purge to remove them."## Fixed while building it@@@ -9,7 +9,7 @@ their authority to Chat **one table at a time**, each move reversible:| `chat_ai_summaries` | `server/ai/chat-ai.js` (global, user, relay-user, anon insights) | Live's chat AI calls Chat's `upsertChatAiSummary` || `user_tags` | none (chat tags are read-only in Live since the game moved to OpenVibe.Games) | data only; Chat has `grantUserTag` / `revokeUserTag` || `emotes` | `POST/PATCH/DELETE /api/emotes`, Media asset-sync (`media_url`, `media_asset_id`) | Live's routes and asset-sync call Chat's `createEmote`, `updateEmote`, `deleteEmote`, `setEmoteMedia` |--| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow` and alert sounds are written by Chat itself |++| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow`, `/subonly` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow`, `/subonly` and alert sounds are written by Chat itself || `channel_moderators` | `POST/DELETE /api/channels/:id/mods` | Live's routes call Chat's `addChannelModerator` / `removeChannelModerator` |`media_requests` is **not** part of this: it is not a chat table. Its writers are Live's media@@@ -58,7 +58,7 @@ at once. Live's table is from then on a read mirror: Chat's triggers queue everyin `live_mirror_outbox` and the mirror sends it to Live (`/internal/chat-effects/mirror`, as forChat's own tables), which takes staged rows only for a table Chat writes. Live's readers do notchange. Chat's chat server reads `channel_moderators` and `channel_moderation_settings` in place,--and writes `/slow` and alert sounds itself.++and writes `/slow`, `/subonly` and alert sounds itself.**The flip back** (`{"authority":"live"}`): Chat sends Live every queued change of the table andrefuses to give it back while any is left; then Live records `live` and captures again. Running a@@@ -13,10 +13,13 @@* OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… [OV_PARITY_TOKEN_STREAMER=…] \* OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer \* [OV_PARITY_SOUND=honk] [OV_PARITY_ORIGIN=https://openvibe.live] [OV_PARITY_CHANNEL_USER_ID=…] \++ * [OV_PARITY_SUBSCRIBER=a] [OV_PARITY_A_BLOCKS_B=1] \* node scripts/parity.js [--apply] [--only join,send,…]** Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MOD moderates the* stream's channel (a channel moderator or its owner), STREAMER owns it (the purge; optional).++ * OV_PARITY_SUBSCRIBER=a: A holds an active Live subscription to the test channel and B does not++ * (the subonly scenario). OV_PARITY_A_BLOCKS_B=1: A has blocked B on openvibe.network (blocked).** Without --apply this is a dry run: it prints what each scenario would do and as whom, and opens* no connection. With --apply it first signs each token in (one WebSocket join each) and reads the@@@ -24,6 +27,7 @@* OV_PARITY_TEST_ACCOUNTS: the messages, DMs, bans, timeouts, slow mode and purges then only ever* touch dedicated test accounts and their own channel. Never point --apply at a real channel.* Tokens are never printed. Exit 0 = no scenario failed (gaps and skips are reported, not failed).++ * A driver may offer block(blocker, blocked, active) to set a network block itself (the test does).*/'use strict';@@@ -53,8 +57,10 @@ const lower = (s) => String(s || '').toLowerCase();* }*/class Parity {-- constructor({ driver, tokens, streamId, channelUserId = null, sound = null, testAccounts = [], log = () => {} }) {++ constructor({ driver, tokens, streamId, channelUserId = null, sound = null, testAccounts = [], subscriber = null, aBlocksB = false, log = () => {} }) {this.driver = driver;++ this.subscriber = subscriber ? lower(subscriber) : null;++ this.aBlocksB = !!aBlocksB;this.tokens = tokens;this.streamId = Number(streamId);this.ownerId = channelUserId ? Number(channelUserId) : null;@@@ -356,9 +362,12 @@ const SCENARIOS = [},{key: 'purge', kind: 'destructive', as: ['streamer', 'a', 'b'], needs: ['a', 'b', 'streamer'],-- does: 'STREAMER purges a time range of the stream (the dashboard’s ISO range): A and B get the purge frame; the lines in the range are gone from history, the one before stays',++ does: 'STREAMER purges a time range of the stream (the dashboard’s ISO range): A and B get the purge frame; a channel popout and the global feed drop the same ids; the range is gone from history (cursor reads name it in deleted_ids), the line before stays',async run(p) {const a = await p.join('a'); const b = await p.join('b');++ // Other surfaces that show the stream's lines: a channel-room popout and the global feed.++ const pop = await p.join('a', { stream: false, channel: true });++ const feed = await p.join('a', { stream: false });const before = p.text('before the purge');await p.say(a, before);const kept = await p.saw(b, before);@@@ -379,10 +388,17 @@ const SCENARIOS = [assert.strictEqual(r.status, 200, r.text);assert.ok(r.body.deleted >= 2, `purged ${r.body.deleted}`);for (const ws of [a, b]) await ws.next((m) => m.type === 'purge' && m.from === from && m.to === to);++ const both = (m) => m.type === 'delete-messages' && [m1.id, m2.id].every((id) => (m.ids || []).includes(id));++ await pop.next(both);++ await feed.next(both);const ids = (await p.http('GET', `/api/chat/${p.streamId}/history?limit=200`)).body.messages.map((m) => m.id);assert.ok(!ids.includes(m1.id) && !ids.includes(m2.id), 'the range is gone from history');assert.ok(ids.includes(kept.id), 'the line before the range stays');-- return `${r.body.deleted} purged`;++ for (const room of [`/api/chat/channel/${p.ownerId}/history`, '/api/chat/global/history']) {++ const d = (await p.http('GET', `${room}?after_id=${m2.id}`)).body;++ assert.ok([m1.id, m2.id].every((id) => (d.deleted_ids || []).includes(id)), `${room}: a cursor read names the purged ids`);++ }++ return `${r.body.deleted} purged; the popout and the global feed dropped them too`;},},{@@@ -395,6 +411,8 @@ const SCENARIOS = [assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, secs);await p.system(a, `Slow mode enabled: ${secs}s between messages`);try {++ // The saved setting is what chat enforces: a new socket reads it back.++ assert.strictEqual((await p.join('b')).auth.slowmode_seconds, secs, 'the saved slow mode is read back');// Everyone may share one address (the CLI): let the /slow line's window pass first.await p.sleep(secs * 1000 + 200);const one = p.text('slow one'); const two = p.text('slow two');@@@ -423,10 +441,32 @@ const SCENARIOS = [},},{-- key: 'subonly', kind: 'gap', as: [], needs: [],-- does: 'sub-only mode: a non-subscriber is refused, a subscriber may talk',++ key: 'subonly', kind: 'destructive', as: ['mod', 'a', 'b', 'anon'], needs: ['a', 'b', 'mod'],++ does: 'MOD /subonly: B (no subscription) and an anonymous viewer are refused and reach nobody; A (an active subscriber) and MOD talk; a new socket is told; /subonly off lets B talk again',async run(p) {-- p.gap('no sub-only mode exists: no channel setting, no command and no check in Chat or Live (followers-only exists), and Chat has no subscriber lookup in live-context (subscriptions are Live’s)');++ if (p.subscriber !== 'a') p.skip('needs A to hold an active subscription to the test channel and B none (then OV_PARITY_SUBSCRIBER=a)');++ const a = await p.join('a'); const b = await p.join('b'); const mod = await p.join('mod'); const anon = await p.join('anon');++ const refused = (m) => m.type === 'system' && /^This chat is in sub-only mode/.test(m.message);++ await p.say(mod, '/subonly');++ assert.strictEqual((await b.next((m) => m.type === 'subonly')).enabled, true);++ await p.system(b, 'Sub-only mode enabled: only subscribers and moderators can chat.');++ try {++ assert.strictEqual((await p.join('b')).auth.sub_only, true, 'a new socket is told (the saved setting)');++ const nb = p.text('B has no subscription');++ await p.say(b, nb);++ assert.match((await b.next(refused)).message, /only subscribers/);++ const na = p.text('anonymous in sub-only');++ await p.say(anon, na);++ assert.match((await anon.next(refused)).message, /sign in and subscribe/);++ assert.ok(await a.none((m) => m.type === 'chat' && (m.message === nb || m.message === na), QUIET_MS), 'refused lines reach nobody');++ const ya = p.text('A subscribes'); await p.say(a, ya); await p.saw(b, ya);++ const ym = p.text('MOD is exempt'); await p.say(mod, ym); await p.saw(b, ym);++ } finally {++ await p.say(mod, '/subonly off');++ await p.system(a, 'Sub-only mode disabled.');++ }++ const back = p.text('B after sub-only'); await p.say(b, back); await p.saw(a, back);++ return 'non-subscriber and anonymous refused; subscriber and moderator talk';},},{@@@ -497,10 +537,53 @@ const SCENARIOS = [},},{-- key: 'blocked', kind: 'gap', as: [], needs: [],-- does: 'reconnect convergence for blocked messages: lines from someone the reader blocked (network blocks) are hidden live and after a reconnect',++ key: 'blocked', kind: 'destructive', as: ['a', 'b', 'mod'], needs: ['a', 'b', 'mod'],++ does: 'A blocked B on the network (one-way): B’s lines never reach A, live or after A reconnects (?after_id= and a fresh page, read as A: channel, stream and global); MOD sees them; A’s lines still reach B',async run(p) {-- p.gap('public chat does not apply blocks: network.block.changed and dm_blocks are honoured for DMs and calls only (server/chat/network-blocks.js, dm.js isBlockedEither); room broadcasts and history pages show a blocked person’s lines to the blocker, live and after a reconnect alike, and Live’s chat.js hides nothing either');++ if (p.driver.block) await p.driver.block('a', 'b', true);++ else if (!p.aBlocksB) p.skip('needs A to have blocked B on openvibe.network (then OV_PARITY_A_BLOCKS_B=1)');++ try {++ const room = `/api/chat/channel/${p.ownerId}/history`;++ let a = await p.join('a'); const b = await p.join('b'); const mod = await p.join('mod');++ const feed = await p.join('a', { stream: false });++ const viewA = new RoomView((await p.http('GET', `${room}?limit=500`, { as: 'a' })).body); const fromA = a.all.length;++ const t1 = p.text('B speaks (A blocked B)');++ await p.say(b, t1);++ const m1 = await p.saw(mod, t1);++ const isB = (m) => m.type === 'chat' && m.user_id === p.users.b.id;++ assert.ok(await a.none(isB, QUIET_MS), 'live: B’s line does not reach A');++ assert.ok(await feed.none(isB, 50), 'nor A’s global feed');++ const t2 = p.text('A still reaches B');++ await p.say(a, t2);++ await p.saw(b, t2);++ viewA.frames(a.all.slice(fromA));++ await p.left(a);++ const t3 = p.text('B while A is away');++ await p.say(b, t3);++ const m3 = await p.saw(mod, t3);++ a = await p.join('a');++ const d = await p.http('GET', `${room}?after_id=${viewA.cursor}`, { as: 'a' });++ assert.strictEqual(d.status, 200);++ viewA.delta(d.body);++ assert.ok(!viewA.ids.has(m1.id) && !viewA.ids.has(m3.id), 'A’s cursor read leaves B’s lines out');++ assert.ok(viewA.cursor >= m3.id, 'the cursor still moves past them');++ const hidden = [m1.id, m3.id];++ for (const path of [room, `/api/chat/${p.streamId}/history`, '/api/chat/global/history']) {++ const asA = (await p.http('GET', `${path}?limit=500`, { as: 'a' })).body.messages.map((m) => m.id);++ assert.ok(!hidden.some((id) => asA.includes(id)), `${path}: a fresh page read as A`);++ const asMod = (await p.http('GET', `${path}?limit=500`, { as: 'mod' })).body.messages.map((m) => m.id);++ assert.ok(hidden.every((id) => asMod.includes(id)), `${path}: everyone else still reads them`);++ }++ } finally {++ if (p.driver.block) await p.driver.block('a', 'b', false);++ }++ if (!p.driver.block) return 'hidden from A live and after a reconnect';++ // Unblocked: B reaches A again.++ const a = await p.join('a'); const b = await p.join('b');++ const t4 = p.text('B after the unblock');++ await p.say(b, t4);++ await p.saw(a, t4);++ return 'hidden from A live and after a reconnect; back after the unblock';},},];@@@ -578,6 +661,8 @@ function configFrom(env) {sound: env.OV_PARITY_SOUND || null,tokens: { a: env.OV_PARITY_TOKEN_A || null, b: env.OV_PARITY_TOKEN_B || null, mod: env.OV_PARITY_TOKEN_MOD || null, streamer: env.OV_PARITY_TOKEN_STREAMER || null },testAccounts: String(env.OV_PARITY_TEST_ACCOUNTS || '').split(',').map((s) => s.trim()).filter(Boolean),++ subscriber: env.OV_PARITY_SUBSCRIBER ? String(env.OV_PARITY_SUBSCRIBER).trim().toLowerCase() : null,++ aBlocksB: env.OV_PARITY_A_BLOCKS_B === '1',};}@@@ -587,7 +672,7 @@ const LABEL = { pass: 'PASS', gap: 'GAP ', skip: 'SKIP', fail: 'FAIL' };async function main(argv = process.argv.slice(2), env = process.env, log = console.log, { driver = null } = {}) {let o;try { o = parseArgs(argv); } catch (err) { log(err.message); return 2; }-- if (o.help) { log(require('fs').readFileSync(__filename, 'utf8').split('\n').slice(1, 32).join('\n')); return 0; }++ if (o.help) { log(require('fs').readFileSync(__filename, 'utf8').split('\n').slice(1, 30).join('\n')); return 0; }const cfg = configFrom(env);const chosen = SCENARIOS.filter((s) => !o.only || o.only.includes(s.key));if (o.only && chosen.length !== o.only.length) { log(`unknown scenario in --only (known: ${SCENARIOS.map((s) => s.key).join(', ')})`); return 2; }@@@ -607,7 +692,9 @@ async function main(argv = process.argv.slice(2), env = process.env, log = consoconst missing = (s.needs || []).filter((r) => !cfg.tokens[r]);const would = s.kind === 'gap' ? 'reports a gap, touches nothing': missing.length ? `would skip: needs ${missing.map((r) => `OV_PARITY_TOKEN_${r.toUpperCase()}`).join(', ')}`-- : s.key === 'soundboard' && !cfg.sound ? 'would skip: needs OV_PARITY_SOUND' : `as ${s.as.join(', ')}`;++ : s.key === 'soundboard' && !cfg.sound ? 'would skip: needs OV_PARITY_SOUND'++ : s.key === 'subonly' && cfg.subscriber !== 'a' ? 'would skip: needs OV_PARITY_SUBSCRIBER=a'++ : s.key === 'blocked' && !cfg.aBlocksB ? 'would skip: needs OV_PARITY_A_BLOCKS_B=1' : `as ${s.as.join(', ')}`;log(` ${s.key.padEnd(11)} ${s.kind.padEnd(11)} ${would}`);log(` ${s.does}`);}@@@ -618,7 +705,7 @@ async function main(argv = process.argv.slice(2), env = process.env, log = conso}if (problems.length) { for (const p of problems) log(p); return 2; }-- const p = new Parity({ driver: driver || networkDriver(cfg), tokens: cfg.tokens, streamId: cfg.streamId, channelUserId: cfg.channelUserId, sound: cfg.sound, testAccounts: cfg.testAccounts, log });++ const p = new Parity({ driver: driver || networkDriver(cfg), tokens: cfg.tokens, streamId: cfg.streamId, channelUserId: cfg.channelUserId, sound: cfg.sound, testAccounts: cfg.testAccounts, subscriber: cfg.subscriber, aBlocksB: cfg.aBlocksB, log });try { await p.resolve(); } catch (err) { log(`could not resolve the accounts and the stream: ${err.message}`); return 2; }const outside = p.untested();if (outside.length) {@@@ -31,6 +31,7 @@ const ttsEngine = require('./tts-engine');const soundboard = require('./soundboard-service');const audioQueue = require('./audio-queue');const dm = require('./dm');++const networkBlocks = require('./network-blocks');const vipBadges = require('../vip/badges');// Cosmetics and tags are Live's (monetization/cosmetics, game/tags); read through live-context.const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };@@@ -52,6 +53,11 @@ const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';++// Sub-only mode (WS-I task 6): who may chat is decided in _chatRulesBlock.++const SUB_ONLY_REFUSED = 'This chat is in sub-only mode: only subscribers of this channel can chat.';++const SUB_ONLY_ANON = 'This chat is in sub-only mode: sign in and subscribe to chat.';++// A person's own lines: what someone who blocked them on the network no longer gets (network-blocks.js).++const LINE_FRAMES = new Set(['chat', 'tts', 'tts-audio']);const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';// Built-in slur categories and normalization are defined in moderation-utils.js// (single source of truth — browser-side chat.js mirrors the same patterns).@@@ -79,8 +85,13 @@ class ChatServer {/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */this.rateLimits = new Map();this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second-- /** @type {Map<number, number>} streamId → slow mode ms (0 = off, default rate limit applies) */-- this.slowModeByStream = new Map();++ // Slow mode and sub-only mode are the channel's saved settings (slowModeMs, _chatRulesBlock);++ // channelId → { slow, sub } last announced to its room (_channelSettingsSeen).++ this._announcedModes = new Map();++ this._modeWrites = new Map(); // channelId → /slow or /subonly writes running++ /** @type {WeakMap<object, Set<string>|null>} frame → the subjects who blocked its author (_blockersOf) */++ this._blockMemo = new WeakMap();++ ctx.onChannelSettings((channelId, settings) => this._channelSettingsSeen(channelId, settings));this.heartbeatInterval = null;// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time./** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */@@@ -383,7 +394,7 @@ class ChatServer {// Slow mode of the stream that governs the room (an offline/channel-room join has no// stream id of its own but posts into the live room).const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);-- const streamSlowMs = (slowStreamId && this.slowModeByStream.get(slowStreamId)) || 0;++ const streamSlowMs = this.slowModeMs(slowStreamId);// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep// the flood limit only, so they can moderate and turn slow mode off again at once.const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);@@@ -397,6 +408,7 @@ class ChatServer {switch (msg.type) {case 'chat':++ await this._warmSubOnly(client).catch(() => {});this.handleChatMessage(ws, client, msg);break;case 'self-delete-history':@@@ -456,7 +468,8 @@ class ChatServer {setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);// Send identity confirmation so the client knows who it isconst displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;-- const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;++ const roomStreamId = client.streamId || this._moderationStreamFor(client);++ const streamSlowSec = Math.round(this.slowModeMs(roomStreamId) / 1000);const streamSettings = this._getChannelChatSettings(client.streamId);this.sendTo(ws, {type: 'auth',@@@ -467,6 +480,7 @@ class ChatServer {role: client.user ? client.user.role : 'anon',user_id: client.user?.id || null,slowmode_seconds: streamSlowSec,++ sub_only: !!(roomStreamId && this._getChannelChatSettings(roomStreamId).sub_only),allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,@@@ -1073,6 +1087,18 @@ class ChatServer {return true;}++ // Sub-only: people with an active subscription to this channel (Live's subscriptions; Network++ // VIP does not count), the streamer, channel moderators and chat staff. Unknown (Live not++ // asked yet, or unreachable) counts as not subscribed; _warmSubOnly asked before this line.++ if (chatSettings.sub_only && !isStaff && !canModerateThisStream) {++ const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;++ const allowed = !!(client.user && ownerId && (Number(ownerId) === Number(client.user.id) || ctx.isSubscriber(client.user.id, ownerId)));++ if (!allowed) {++ this.sendTo(ws, { type: 'system', message: client.user ? SUB_ONLY_REFUSED : SUB_ONLY_ANON });++ return true;++ }++ }++// Links disabled — exempt [gif:url] tags (validated separately)if (chatSettings.links_allowed === 0 && !isStaff) {const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');@@@ -1152,6 +1178,22 @@ class ChatServer {return true;}++ /**++ * Before a line is judged in a sub-only room: have the sender's subscription answer. Asks Live++ * (briefly) when there is none yet or when a cached "no" is a few seconds old (they may have just++ * subscribed); a "yes" is served from the cache. Nothing is asked outside sub-only rooms.++ */++ async _warmSubOnly(client) {++ if (!client || !client.user) return;++ const modStreamId = client.streamId || this._moderationStreamFor(client);++ if (!modStreamId || !this._getChannelChatSettings(modStreamId).sub_only) return;++ if (permissions.canModerateStream(client.user, modStreamId)) return;++ const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;++ if (!ownerId || Number(ownerId) === Number(client.user.id)) return;++ const state = ctx.subscriberState(client.user.id, ownerId);++ if (state !== true) await ctx.ensureSubscriber(client.user.id, ownerId, { maxAgeMs: state === false ? 5000 : 0 });++ }++/** /me and /tts put text in the room: the same rules as a chat line. */_commandTextBlocked(ws, client, text) {const modStreamId = client.streamId || this._moderationStreamFor(client);@@@ -1542,7 +1584,7 @@ class ChatServer {message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +(this.canModerate(client)-- ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear, /slow <seconds>, /skiptts [id], /cleartts`++ ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`: ''),});break;@@@ -1558,6 +1600,7 @@ class ChatServer {type: 'tts',username: client.user?.display_name || client.anonId,core_username: client.user?.username || null,++ user_id: client.user?.id || null,message: args,timestamp: new Date().toISOString(),};@@@ -1651,6 +1694,7 @@ class ChatServer {type: 'chat',username,core_username: client.user?.username || null,++ user_id: client.user?.id || null,role: client.user?.role || 'anon',message: `* ${username} ${args}`,is_action: true,@@@ -1694,8 +1738,11 @@ class ChatServer {}case 'clear':++ // Twitch semantics: every screen in the room is cleared, nothing is deleted. Removing++ // lines for good is a purge (dashboard) or a delete.if (this.canModerate(client)) {this._broadcastToRoom(client, { type: 'clear' });++ this.sendTo(ws, { type: 'system', message: 'Chat cleared on screen; messages stay in history — use purge to remove them.' });this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');} else {this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });@@@ -1703,41 +1750,25 @@ class ChatServer {break;case 'slow': {-- if (this.canModerate(client)) {-- let seconds;-- if (args === 'off' || args === 'disable' || args === '0') {-- seconds = 0;-- } else {-- seconds = parseInt(args);-- if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;-- }-- // Per-stream slow mode (not global)-- if (client.streamId) {-- this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);-- // Persist (by Live, or here once Chat writes channel_moderation_settings: live-context effects)-- try {-- const stream = ctx.getStreamById(client.streamId);-- if (stream?.channel_id) {-- ctx.effects.updateChannelModerationSettings(stream.channel_id, { slow_mode_seconds: seconds }, client.user?.id || null).catch(() => { /* non-critical */ });-- }-- } catch { /* non-critical */ }-- }-- // Dedicated slowmode event so clients can show/hide UI-- this._broadcastToRoom(client, {-- type: 'slowmode',-- seconds,-- });-- const msg = seconds > 0-- ? `Slow mode enabled: ${seconds}s between messages`-- : 'Slow mode disabled.';-- this._broadcastToRoom(client, {-- type: 'system',-- message: msg,-- });-- this.logChatModeration(client, 'slowmode_update', { seconds });++ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }++ let seconds;++ if (args === 'off' || args === 'disable' || args === '0') {++ seconds = 0;} else {-- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });++ seconds = parseInt(args);++ if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;}++ this._setChannelModes(ws, client, { slow_mode_seconds: seconds }, 'slow mode')++ .then((ok) => { if (ok) this.logChatModeration(client, 'slowmode_update', { seconds }); });++ break;++ }++++ case 'subonly': {++ // /subonly [on] · /subonly off — the streamer, channel mods and chat staff.++ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }++ const off = ['off', 'disable', '0', 'false', 'no'].includes(String(args || '').trim().toLowerCase());++ this._setChannelModes(ws, client, { sub_only: off ? 0 : 1 }, 'sub-only mode')++ .then((ok) => { if (ok) this.logChatModeration(client, 'subonly_update', { enabled: !off }); });break;}@@@ -2296,6 +2327,33 @@ class ChatServer {}).catch(() => { /* best-effort */ });}++ /**++ * Public chat and blocks (network-blocks.js, one-way): the subjects who blocked the author of a++ * line frame (chat, /me, /tts and its audio), or null — the usual case, everyone gets it.++ * Computed once per frame object, however many rooms it goes to.++ */++ _blockersOf(data) {++ if (!data || typeof data !== 'object' || !LINE_FRAMES.has(data.type)) return null;++ if (this._blockMemo.has(data)) return this._blockMemo.get(data);++ let out = null;++ try {++ let authorId = data.user_id || null;++ if (!authorId && data.type === 'tts-audio' && /^user:/.test(String(data.sender_key || ''))) {++ authorId = ctx.getUserByUsername(String(data.sender_key).slice(5))?.id || null;++ }++ const subject = authorId ? db.subjectFor(authorId) : null;++ const list = subject ? networkBlocks.blockersOf(subject) : [];++ if (list.length) out = new Set(list);++ } catch { out = null; }++ this._blockMemo.set(data, out);++ return out;++ }++++ /** Does this socket's person not get the frame (they blocked its author)? */++ _blockedFor(client, blockers) {++ return !!(blockers && client.user && blockers.has(this._subjectOfUser(client.user)));++ }++/*** Deliver to the room a client is in: its stream room when in a stream, its channel room when* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every@@@ -2309,8 +2367,9 @@ class ChatServer {broadcastToStream(streamId, data) {const msg = JSON.stringify(data);++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2324,8 +2383,10 @@ class ChatServer {*/broadcastToChannelRoom(channelUserId, streamId, data) {const msg = JSON.stringify(data);++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;++ if (this._blockedFor(client, blockers)) continue;const inChannel = channelUserId && client.channelUserId === channelUserId;const inStream = streamId && client.streamId === streamId;if (inChannel || inStream) ws.send(msg);@@@ -2343,8 +2404,9 @@ class ChatServer {try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }if (!username) return;const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {ws.send(globalMsg);}}@@@ -2352,10 +2414,11 @@ class ChatServer {broadcastGlobal(data) {const msg = JSON.stringify(data);++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {// Pure global clients only (see forwardToGlobal) — channel/stream viewers get// global activity via their dedicated cross-feed socket, not their main one.-- if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2434,7 +2497,9 @@ class ChatServer {source_managed_id: info.managedId,source_is_live: 1, // this path only fires for a live send});++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {++ if (this._blockedFor(client, blockers)) continue;// Only PURE global clients (no stream AND no channel) — otherwise an// offline-channel viewer's main socket (streamId null, channelUserId set)// would render this via addChatMessage AND get it again as a cross-feed on@@@ -2469,9 +2534,10 @@ class ChatServer {source_channel: stream.username || null,source_is_live: 1,});++ const blockers = this._blockersOf(data);for (const [ws, client] of this.clients) {if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)-- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {ws.send(payload);}}@@@ -2482,7 +2548,11 @@ class ChatServer {if (!Array.isArray(ids) || ids.length === 0) return;const payload = { type: 'delete-messages', ids };if (streamId) {-- this.broadcastToStream(streamId, payload);++ // The stream's room, the rest of its channel room (other slots, the offline room, a++ // channel popout) and the global feed: everywhere the lines were shown.++ let ownerId = null;++ try { ownerId = ctx.getStreamById(streamId)?.user_id || null; } catch { ownerId = null; }++ this.broadcastToChannelRoom(ownerId, streamId, payload);this.forwardToGlobal(streamId, payload);return;}@@@ -2672,8 +2742,9 @@ class ChatServer {*/broadcastAll(data) {const msg = JSON.stringify(data);-- for (const [ws] of this.clients) {-- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ const blockers = this._blockersOf(data);++ for (const [ws, client] of this.clients) {++ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2719,6 +2790,98 @@ class ChatServer {} catch { /* non-critical */ }}++ /**++ * A room's slow mode in ms: its channel's saved slow_mode_seconds (channel moderation settings,++ * the dashboard's value and /slow's). Read from the policy cache, so a restart keeps it.++ */++ slowModeMs(streamId) {++ if (!streamId) return 0;++ return Math.max(0, parseInt(this._getChannelChatSettings(streamId).slow_mode_seconds, 10) || 0) * 1000;++ }++++ /** streamId → slow mode ms for the streams with sockets here (Live's presence read). */++ get slowModeByStream() {++ const out = new Map();++ for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, this.slowModeMs(c.streamId));++ return out;++ }++++ /** The channel a client's room belongs to: { channel, ownerId } or null (global chat). */++ async _channelOfRoom(client) {++ const modStreamId = client.streamId || this._moderationStreamFor(client);++ const stream = modStreamId ? ctx.getStreamById(modStreamId) : null;++ const ownerId = (stream && stream.user_id) || client.channelUserId || null;++ let channel = stream && stream.channel_id ? ctx.getChannelById(stream.channel_id) : null;++ if (!channel && ownerId) channel = await ctx.ensureChannelForUser(ownerId);++ return channel ? { channel, ownerId: channel.user_id || ownerId } : null;++ }++++ _modesOf(settings) {++ return { slow: Math.max(0, parseInt(settings && settings.slow_mode_seconds, 10) || 0), sub: settings && Number(settings.sub_only) ? 1 : 0 };++ }++++ /**++ * /slow and /subonly. The channel's saved setting is the truth: it is written where the table's++ * authority says (Live's effect, or here once Chat writes channel_moderation_settings), then the++ * channel's whole room hears it (every live slot, the offline room, popouts). Nothing changes when++ * the write fails. → true when saved.++ */++ async _setChannelModes(ws, client, fields, label) {++ let room = null;++ try { room = await this._channelOfRoom(client); } catch { room = null; }++ if (!room) { this.sendTo(ws, { type: 'system', message: `The ${label} is set per channel: use it in a channel's chat.` }); return false; }++ const id = Number(room.channel.id);++ // While the write runs, policy reads of this channel are not announced (one in flight may++ // still carry the old value); the command announces the result itself.++ this._modeWrites.set(id, (this._modeWrites.get(id) || 0) + 1);++ try {++ await ctx.effects.updateChannelModerationSettings(id, fields, client.user ? client.user.id : null);++ } catch (err) {++ this.sendTo(ws, { type: 'system', message: err && err.status === 403 ? 'You do not have permission.' : `Could not change the ${label} right now. Try again.` });++ return false;++ } finally {++ const n = (this._modeWrites.get(id) || 1) - 1;++ if (n > 0) this._modeWrites.set(id, n); else this._modeWrites.delete(id);++ }++ const next = this._modesOf(ctx.getChannelModerationSettings(id));++ if (fields.slow_mode_seconds !== undefined) next.slow = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);++ if (fields.sub_only !== undefined) next.sub = fields.sub_only ? 1 : 0;++ this._announcedModes.set(id, next);++ // A command is always answered in the room, even when the value did not change.++ this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);++ return true;++ }++++ /** Tell a channel's room what changed: the slowmode / subonly frame and a system line for each. */++ _announceModes(ownerUserId, streamId, prev, next) {++ if (!ownerUserId && !streamId) return;++ const say = (frame) => this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);++ if (prev.slow !== next.slow) {++ say({ type: 'slowmode', seconds: next.slow });++ say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });++ }++ if (prev.sub !== next.sub) {++ say({ type: 'subonly', enabled: !!next.sub });++ say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });++ }++ }++++ /**++ * A channel's settings were read anew (live-context.onChannelSettings): when its slow mode or++ * sub-only mode differs from what its room was last told (the dashboard changed it), tell the++ * room. The first read after a start only records the values.++ */++ _channelSettingsSeen(channelId, settings) {++ const id = Number(channelId);++ if (!id || this._modeWrites.has(id)) return;++ const next = this._modesOf(settings);++ const prev = this._announcedModes.get(id);++ this._announcedModes.set(id, next);++ if (!prev || (prev.slow === next.slow && prev.sub === next.sub)) return;++ const channel = ctx.getChannelById(id);++ if (channel && channel.user_id) this._announceModes(channel.user_id, null, prev, next);++ }++/*** Get channel moderation settings for a stream.* Caches the channel lookup to avoid repeated DB queries.@@@ -9,6 +9,11 @@* DMs (./dm.js isBlockedEither) refuse a conversation, a group invite or a direct message between two* people when either blocked the other, here or in Chat's own dm_blocks. Blocks are between Network* subjects; Chat's people are Live ids, mapped through ctx_users.subject_id (db.subjectFor).++ *++ * Public chat (WS-I task 6) is one-way: the person who blocked someone no longer gets that person's++ * lines, live (chat-server.js skips their sockets: blockersOf) or in history reads (routes.js drops++ * them from pages and cursor reads for that reader: blockedUserIds). Everyone else, the blocked++ * person included, sees the room as before; moderation tools and logs show everything.*/'use strict';@@@ -66,7 +71,23 @@ function eitherBlockedUsers(userIdA, userIdB) {return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));}++/** The subjects who blocked `blocked` (a subject), active blocks only. One indexed read. */++function blockersOf(blocked) {++ if (!SUBJECT_RE.test(String(blocked || ''))) return [];++ ensureSchema();++ return db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked]).map((r) => r.blocker_subject);++}++++/** The Live user ids a signed-in reader blocked (through their subjects, the ctx_users projection). */++function blockedUserIds(user) {++ const subject = user && (user.subject_id || db.subjectFor(user.id));++ if (!SUBJECT_RE.test(String(subject || ''))) return new Set();++ ensureSchema();++ return new Set(db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject++ WHERE b.blocker_subject = ? AND b.active = 1`, [subject]).map((r) => Number(r.id)));++}++/** For tests: forget that the table was created (the table stays). */function _reset() { ready = false; }--module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, _reset };++module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, blockersOf, blockedUserIds, _reset };@@@ -17,6 +17,20 @@ const ctx = require('../live-context');const { optionalAuth, requireAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const historyStore = require('./history-store');++const networkBlocks = require('./network-blocks');++++/**++ * A signed-in reader does not get the lines of people they blocked on the network (one-way; only++ * their own view: network-blocks.js). Pages and cursor reads alike; latest_id and deleted_ids stay++ * the room's, so the reader's cursor moves on as everyone's does. Such answers are per reader.++ */++function withoutBlocked(req, res, rows) {++ if (!req.user) return rows;++ res.set('Cache-Control', 'private, no-store');++ const hidden = networkBlocks.blockedUserIds(req.user);++ if (!hidden.size) return rows;++ return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));++}const router = express.Router();@@@ -481,6 +495,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {const out = afterId != null? historyStore.delta('global', { afterId, limit, channelUsername }): historyStore.page('global', { limit, before, channelUsername });++ out.messages = withoutBlocked(req, res, out.messages);await loadDecor(out.messages);out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));res.json(out);@@@ -579,6 +594,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {let complete = true;if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);++ rows = withoutBlocked(req, res, rows);await loadDecor(rows);const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));@@@ -623,6 +639,7 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {const out = req.query.after_id != null? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit }): historyStore.page(room, { limit: req.query.limit, before: req.query.before });++ out.messages = withoutBlocked(req, res, out.messages);await loadDecor(out.messages);out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));let liveSlots = [], channel = null;@@@ -718,14 +735,25 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);-- // Broadcast delete event to live chat++ // Every surface that showed those lines: the stream's sockets get the range (Live's chat.js++ // drops it by time, with its "purged" notice) and, by id, the rest of the channel room (other++ // slots, the offline room, a channel popout) and the global feed. Cursor reads report them++ // in deleted_ids (history-store.js).try {const chatServer = require('./chat-server');const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };++ const ids = (result && result.ids) || [];++ const gone = { type: 'delete-messages', ids };if (effectiveStreamId) {chatServer.broadcastToStream(effectiveStreamId, payload);++ if (ids.length) {++ const ownerId = ctx.getStreamById(effectiveStreamId)?.user_id || null;++ chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);++ chatServer.forwardToGlobal(effectiveStreamId, gone);++ }} else {chatServer.broadcastGlobal(payload);++ if (ids.length) chatServer.broadcastGlobal(gone);}} catch { /* chat server may not be initialized */ }@@@ -117,6 +117,7 @@ const ADDED_COLUMNS = [['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media['emotes', 'media_asset_id', 'INTEGER'],['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it++ ['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too];function initDb({ captureMirror = false } = {}) {@@@ -443,7 +444,8 @@ function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {[deletedBy, ...params]);_announceDeleted(ids);-- return res;++ // The ids too, so the purge reaches every surface that showed them (not only the stream's sockets).++ return Object.assign(res, { ids });});}@@@ -1024,10 +1026,11 @@ function upsertChannelModerationSettings(channelId, fields) {]);}-- // tts_max_length, as Live: after the UPDATE or the fresh INSERT.++ // tts_max_length and sub_only, as Live: after the UPDATE or the fresh INSERT.if (fields.tts_max_length !== undefined) {run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);}++ if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);return _staged(row, 'channel_moderation_settings', [row]);});@@@ -241,7 +241,8 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (sound_max_pitch_cents INTEGER DEFAULT 1200,emote_size_min INTEGER DEFAULT 50,emote_size_max INTEGER DEFAULT 200,-- sounds_mods_only INTEGER DEFAULT 0++ sounds_mods_only INTEGER DEFAULT 0,++ sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators);CREATE TABLE IF NOT EXISTS emotes (@@@ -34,9 +34,13 @@* channels getChannelById, getChannelByUserId (sync) · ensureChannelForUser(uid),* createChannel(uid) (effect)* policy getChannelModerationSettings(channelId), isChannelModerator(uid, channelId),-- * channelLanguage(channelUserId), getChannelAlertSoundsByUser(uid) · invalidateChannel++ * channelLanguage(channelUserId), getChannelAlertSoundsByUser(uid) · invalidateChannel,++ * reloadPolicy(channelId) (a fresh read after a write), onChannelSettings(fn)++ * (fn(channelId, settings) whenever a channel's settings are read anew)* bans isUserBanned(uid, streamId), getIpBan(ip, streamId), isIpBanned(ip, streamId)* follows isFollowing(followerId, streamerId)++ * subscriptions isSubscriber(uid, streamerId), subscriberState(…) → true | false | null (unknown),++ * ensureSubscriber(…) (sub-only chat; Live's active channel subscriptions)* approvals isIpApproved(channelId, ip), approveIp(channelId, ip, by, source) (effect)* decor getCosmeticProfile(uid), getTagProfile(uid), ensureDecor(ids)* settings getSetting(key), ensureSettings(), setSettings(map, actor) (effect)@@@ -65,7 +69,10 @@ const TTL = {settings: 30_000,bans: 10_000,streamRow: 30_000,++ subs: 60_000,};++// A subscription answer older than this is not used at all (sub-only fails closed while Live is away).++const SUB_MAX_STALE_MS = 10 * 60_000;const WARM_TIMEOUT_MS = 2500;const PAGE = 1000;@@@ -113,6 +120,7 @@ function defaultModerationSettings(channelId) {sound_max_speed: 3.0,sound_min_pitch_cents: -1200,sound_max_pitch_cents: 1200,++ sub_only: 0,};}@@@ -328,9 +336,22 @@ async function createChannel(userId) {// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────++// Everyone who wants to know when a channel's settings were read anew (the chat server announces a++// slow mode or sub-only mode the dashboard changed). Called with every fresh read; the listener++// compares with what it last saw.++const _settingsListeners = new Set();++function onChannelSettings(fn) { _settingsListeners.add(fn); return () => _settingsListeners.delete(fn); }++function _emitSettings(channelId, settings) {++ for (const fn of _settingsListeners) {++ try { fn(Number(channelId), settings || defaultModerationSettings(channelId)); } catch (err) { console.warn('[LiveContext] channel settings listener:', err.message); }++ }++}++const _policy = new Swr(TTL.policy, async (channelId) => {const data = await read(`/channels/${channelId}/policy`);if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);++ // While Chat writes the settings itself, Live's copy is a mirror that may lag: not announced.++ if (!_chatWrites('channel_moderation_settings')) _emitSettings(channelId, data.settings || defaultModerationSettings(channelId));return {settings: data.settings || null,moderators: new Set((data.moderator_ids || []).map(Number)),@@@ -369,7 +390,21 @@ function getChannelAlertSoundsByUser(userId) {return pick;}function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }--function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number(channelId)); }++function invalidateChannel(channelId) {++ if (!channelId) return;++ _policy.invalidate(Number(channelId));++ if (_chatWrites('channel_moderation_settings')) _emitSettings(channelId, getChannelModerationSettings(channelId));++}++/**++ * A fresh policy read that starts after the call (Chat just had Live write a setting): a load already++ * in flight may have left before the write, so it is waited for and followed by another.++ */++function reloadPolicy(channelId) {++ const k = Number(channelId);++ if (!k) return Promise.resolve();++ const inflight = _policy.inflight.get(k);++ return (inflight ? inflight.catch(() => {}) : Promise.resolve()).then(() => _policy.refresh(k));++}// ── Bans (Live's `bans`: user, IP and CIDR rows) ────────────────────────────────────────────@@@ -470,6 +505,39 @@ function isFollowing(followerId, streamerId) {return !!(s && s.has(Number(streamerId)));}++// Sub-only chat: does this person hold an ACTIVE subscription to the streamer's channel (Live's++// subscriptions, GET /subscriber)? Network VIP does not count. Cached per (user, streamer) for a++// minute; an answer older than SUB_MAX_STALE_MS, or none yet, is unknown (null), which sub-only++// treats as "no": it fails closed when Live cannot be asked.++const subKey = (userId, streamerId) => `${Number(userId)}|${Number(streamerId)}`;++const _subs = new Swr(TTL.subs, async (key) => {++ const [userId, streamerId] = key.split('|');++ return !!(await read(`/subscriber?user_id=${userId}&streamer_id=${streamerId}`)).subscriber;++});++function subscriberState(userId, streamerId) {++ if (!userId || !streamerId) return false;++ const key = subKey(userId, streamerId);++ const hit = _subs.map.get(key);++ const age = hit ? Date.now() - hit.at : Infinity;++ if (age > TTL.subs) _subs.refresh(key).catch(() => {});++ return age > SUB_MAX_STALE_MS ? null : hit.value;++}++function isSubscriber(userId, streamerId) { return subscriberState(userId, streamerId) === true; }++/** Ask Live when the cached answer is missing or older than `maxAgeMs`; waits at most `timeoutMs`. → true | false | null */++async function ensureSubscriber(userId, streamerId, { maxAgeMs = TTL.subs, timeoutMs = 2000 } = {}) {++ if (!userId || !streamerId) return false;++ const key = subKey(userId, streamerId);++ const hit = _subs.map.get(key);++ if (hit && Date.now() - hit.at <= maxAgeMs) return hit.value;++ let timer;++ await Promise.race([++ _subs.refresh(key).catch((err) => console.warn(`[LiveContext] subscriber ${key}: ${err.message}`)),++ new Promise((r) => { timer = setTimeout(r, timeoutMs); if (timer.unref) timer.unref(); }),++ ]);++ clearTimeout(timer);++ return subscriberState(userId, streamerId);++}++const _approvals = new Swr(TTL.approval, async (key) => {const [channelId, ip] = key.split('|');return !!(await read(`/channels/${channelId}/approved-ip?ip=${encodeURIComponent(ip)}`)).approved;@@@ -629,6 +697,7 @@ function invalidateUser(userId) {for (const [k, v] of _auth) if (v.userId === id) _auth.delete(k);_decor.invalidate(id);_follows.invalidate(id);++ for (const k of [..._subs.map.keys()]) if (k.startsWith(`${id}|`)) _subs.delete(k);}// ── Warm-up (connect / join) ─────────────────────────────────────────────────────────────────@@@ -655,6 +724,10 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));const policy = channel ? _policyFor(channel.id) : null;if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});++ // Sub-only room: the viewer's subscription answer, so their first line does not wait for it.++ if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {++ await ensureSubscriber(user.id, ownerId).catch(() => {});++ }})();await Promise.race([job.catch(() => {}), new Promise((r) => setTimeout(r, WARM_TIMEOUT_MS).unref?.())]);}@@@ -806,11 +879,19 @@ const effects = {ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),// /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat// does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).++ // The saved value is what chat enforces: at 'live' the policy is read again (awaited) before the++ // caller answers, so the next line already follows it. Chat sets slow_mode_seconds and sub_only.updateChannelModerationSettings: (channelId, fields, actorUserId) => {-- if (!_chatWrites('channel_moderation_settings')) return effect('channel-settings', { channel_id: channelId, fields, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; });++ const f = {};++ if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);++ if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;++ if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));++ if (!_chatWrites('channel_moderation_settings')) {++ return effect('channel-settings', { channel_id: channelId, fields: f, actor_user_id: actorUserId })++ .then((r) => reloadPolicy(channelId).catch(() => {}).then(() => r));++ }return Promise.resolve().then(() => {-- if (!channelId || !fields || fields.slow_mode_seconds === undefined) throw new LiveError(400, 'no chat-settable fields');-- db.upsertChannelModerationSettings(Number(channelId), { slow_mode_seconds: Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0) });++ db.upsertChannelModerationSettings(Number(channelId), f);return { ok: true };});},@@@ -860,7 +941,9 @@ module.exports = {getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,// policygetChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,-- defaultModerationSettings,++ reloadPolicy, onChannelSettings, defaultModerationSettings,++ // subscriptions (sub-only chat)++ isSubscriber, subscriberState, ensureSubscriber,// bans, follows, approvals, decorisUserBanned, getIpBan, isIpBanned, invalidateBans, refreshBans,isFollowing, isIpApproved, approveIp, invalidateApprovals,@@@ -873,7 +956,7 @@ module.exports = {// tests_setFetch(fn) { _fetch = fn; },_reset() {-- _policy.clear(); _follows.clear(); _approvals.clear(); _decor.clear(); _auth.clear(); _anon.clear(); _anonSeen.clear(); _streamFetch.clear();++ _policy.clear(); _follows.clear(); _approvals.clear(); _decor.clear(); _auth.clear(); _anon.clear(); _anonSeen.clear(); _streamFetch.clear(); _subs.clear();_bans = { rows: [], version: null, at: 0, cidr: [] }; _settings = { values: {}, at: 0 }; _lastRun.clear();},};@@@ -130,7 +130,9 @@ function createWebRoutes({ config }) {router.get('/', async (req, res) => {const actor = await viewerOf(req);const { messages, latest_id } = historyStore.page('global', { limit: 60 });-- const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat');++ // Not the lines of people this reader blocked on the network (chat/network-blocks.js).++ const hidden = actor.kind === 'user' ? require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();++ const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));const composer = actor.kind === 'user'? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose"><label for="oc-input" class="oc-sr">Message to everyone</label>@@@ -120,6 +120,8 @@ async function boot({ env = {} } = {}) {channels: new Map(), // id → { id, user_id, title }policies: new Map(), // channelId → { settings, moderator_ids }follows: new Map(), // userId → [streamerIds]++ subscribers: new Set(), // `${userId}|${streamerId}`: an active channel subscription++ subscriberDown: false, // GET /subscriber answers 503 (Live cannot say)approved: new Set(), // `${channelId}|${ip}`bans: [], // rowssettings: { tts_enabled: true, tts_per_user_voices: true, gif_tenor_api_key: '' },@@@ -174,6 +176,12 @@ async function boot({ env = {} } = {}) {return send(200, { users: out });}if ((m = /^\/users\/(\d+)\/follows$/.exec(q))) return send(200, { streamer_ids: live.follows.get(Number(m[1])) || [] });++ if (q === '/subscriber') {++ if (live.subscriberDown) return send(503, { error: 'subscription lookup failed' });++ const u = Number(url.searchParams.get('user_id')), st = Number(url.searchParams.get('streamer_id'));++ if (!u || !st) return send(400, { error: 'user_id (or subject) and streamer_id required' });++ return send(200, { subscriber: live.subscribers.has(`${u}|${st}`), user_id: u, streamer_id: st });++ }if (q === '/users/profile') {const n = url.searchParams.get('username');const u = [...live.users.values()].find((x) => x.username.toLowerCase() === String(n).toLowerCase());@@@ -3,15 +3,18 @@* Chat parity (roadmap WS-I task 6). scripts/parity.js's scenarios (join, send, DM, /tts,* soundboard, ban, timeout, purge, slow mode, sub-only mode, the popout, reconnect convergence for* deleted and blocked messages) run here against Chat booted with stubs, with two or three people-- * connected, each from their own address: the same code the CLI runs against a running Chat.++ * connected, each from their own address: the same code the CLI runs against a running Chat. Alice++ * (A) holds an active subscription to the channel (the stub Live's GET /subscriber); the driver sets++ * network blocks itself (network.block.changed, as the Events consumer applies it).** On top, what only a test can reach: Live's moderator paths through the bridge (the context-menu* ban that disconnects, the single-message delete) and the global feed's cursor read (Live's-- * floating widget, openvibe.chat), the DM participant rule on delivery, and the CLI itself: its dry-- * run touches nothing, and --apply refuses to run for an account that is not a named test account.-- *-- * A scenario the product does not support ends as a gap with its reason (sub-only mode, blocks in-- * public chat); nothing here asserts that it works. The matrix is printed at the end.++ * floating widget, openvibe.chat), sub-only failing closed when Live cannot be asked, slow mode as++ * the saved setting (a restart of every cache, the dashboard's value), /clear's answer, blocks++ * leaving moderation logs and DMs as they were, the DM participant rule on delivery, and the CLI++ * itself: its dry run touches nothing, and --apply refuses to run for an account that is not a++ * named test account. Every scenario must pass here: none is a gap or a skip. The matrix is printed++ * at the end.*/const assert = require('assert');const { boot, suite } = require('./helpers');@@@ -22,6 +25,7 @@ let h, p, streamer, mod, alice, bob, dave, channelId, streamId;const IPS = { a: '198.51.100.101', b: '198.51.100.102', mod: '198.51.100.103', streamer: '198.51.100.104', anon: '198.51.100.105', dave: '198.51.100.106' };const results = [];let bridgeSeq = 0;++let blockRev = 0;const bridge = (ops) => h.http('POST', '/internal/live/calls', {token: h.serviceToken(['chat.live_bridge.write', 'chat.message.send']),body: { boot: 'parity-live', ops: ops.map((o) => ({ seq: ++bridgeSeq, ...o })) },@@@ -43,6 +47,11 @@ const driver = {address: (role) => IPS[role],connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),++ // A network block, as the Events consumer applies network.block.changed.++ block: (blocker, blocked, active) => {++ const subject = { a: alice, b: bob, mod, streamer }[blocker].subject_id, target = { a: alice, b: bob, mod, streamer }[blocked].subject_id;++ require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });++ },};t('boot: a channel with a moderator, a live stream, a channel sound, TTS that synthesizes', async () => {@@@ -54,6 +63,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sydave = h.addUser('dave');channelId = h.addChannel(streamer.id, { moderators: [mod.id] });streamId = h.addStream(streamer.id, channelId);++ h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does notawait h.ctx.sync();// TTS without an engine: every utterance is 0.2 s of silence.const tts = require('../server/chat/tts-engine');@@@ -70,6 +80,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sydriver, streamId, sound: 'honk',tokens: { a: alice.token, b: bob.token, mod: mod.token, streamer: streamer.token },testAccounts: ['alice', 'bob', 'moddy', 'streamer'],++ subscriber: 'a',});await p.resolve();assert.deepStrictEqual(Object.fromEntries(Object.entries(p.users).map(([r, u]) => [r, u.id])), { a: alice.id, b: bob.id, mod: mod.id, streamer: streamer.id });@@@ -155,6 +166,97 @@ t('reconnect in global chat (Live’s floating widget, openvibe.chat): Live’sawait p.closeAll();});++t('sub-only fails closed: while Live cannot say whether someone subscribes they are told sub-only is on; the streamer, moderators and a known subscriber talk', async () => {++ const q = new parity.Parity({ driver, tokens: { ...p.tokens, dave: dave.token }, streamId });++ q.ownerId = streamer.id;++ const modWs = await q.join('mod'); const a = await q.join('a');++ await q.say(modWs, '/subonly');++ await a.next((m) => m.type === 'subonly' && m.enabled === true);++ h.live.subscriberDown = true;++ try {++ // Nobody has asked Live about dave; Alice's "yes" is cached from the subonly scenario.++ const daveWs = await q.join('dave');++ await q.sleep(parity.PACE_MS); // dave's last line (the ban test) may be under a second old++ const t1 = q.text('dave while Live cannot say');++ await q.say(daveWs, t1);++ assert.match((await daveWs.next((m) => m.type === 'system' && /^This chat is in sub-only mode/.test(m.message))).message, /only subscribers/);++ assert.ok(await a.none((m) => m.type === 'chat' && m.message === t1), 'fails closed: the line reaches nobody');++ const t2 = q.text('alice, a known subscriber'); await q.say(a, t2); await q.saw(modWs, t2);++ const t3 = q.text('the moderator'); await q.say(modWs, t3); await q.saw(a, t3);++ const own = await q.join('streamer');++ const t4 = q.text('the streamer'); await q.say(own, t4); await q.saw(a, t4);++ } finally {++ h.live.subscriberDown = false;++ await q.say(modWs, '/subonly off');++ await a.next((m) => m.type === 'subonly' && m.enabled === false);++ await q.closeAll();++ }++});++++t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {++ const pol = h.live.policies.get(channelId);++ const a = await p.join('a'); const b = await p.join('b');++ // The dashboard saves 3 s in Live, which tells Chat (the bridge's invalidate: Live chat-remote.js OBSERVED_DB).++ pol.settings = { ...(pol.settings || {}), slow_mode_seconds: 3 };++ await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);++ assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');++ await p.system(b, 'Slow mode enabled: 3s between messages');++ const slowed = async (ws, label) => {++ await p.sleep(3200);++ const one = p.text(`${label} one`), two = p.text(`${label} two`);++ await p.say(ws, one); await p.saw(b, one);++ await p.say(ws, two);++ await p.system(ws, 'Slow down! You are sending messages too fast.');++ assert.ok(await b.none((m) => m.type === 'chat' && m.message === two), `${label}: a slowed line reaches nobody`);++ };++ await slowed(a, 'dashboard slow');++ // A restart: every cache is gone and nothing was announced yet; the value is read back from Live.++ h.ctx._reset();++ h.chatServer._announcedModes.clear();++ const again = await p.join('a');++ assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');++ await slowed(again, 'after a restart');++ // The dashboard turns it off.++ pol.settings.slow_mode_seconds = 0;++ await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);++ assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);++ await p.system(b, 'Slow mode disabled.');++ await p.closeAll();++});++++t('/clear clears screens only and says so: the lines stay in history', async () => {++ const a = await p.join('a'); const modWs = await p.join('mod');++ const line = p.text('still in history after /clear');++ await p.say(a, line);++ const m = await p.saw(modWs, line);++ await p.say(modWs, '/clear');++ await a.next((f) => f.type === 'clear');++ await p.system(modWs, 'Chat cleared on screen; messages stay in history — use purge to remove them.');++ assert.ok((await h.http('GET', `/api/chat/${streamId}/history?limit=50`)).body.messages.some((x) => x.id === m.id), 'still in history');++ await p.closeAll();++});++++t('blocks in public chat leave the rest alone: the streamer who blocked B does not get B’s lines, their moderation log does; DMs are refused both ways as before', async () => {++ driver.block('streamer', 'b', true);++ try {++ const own = await p.join('streamer'); const b = await p.join('b'); const a = await p.join('a');++ const t1 = p.text('B, blocked by the streamer');++ await p.say(b, t1);++ await p.saw(a, t1);++ assert.ok(await own.none((m) => m.type === 'chat' && m.message === t1), 'the blocker does not get it');++ const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&search=${encodeURIComponent(t1)}`, { token: streamer.token });++ assert.strictEqual(logs.status, 200, logs.text);++ assert.ok(logs.body.rows.some((r) => r.message === t1), 'the moderation log shows everything');++ for (const [from, to] of [[bob, streamer], [streamer, bob]]) {++ const conv = await h.http('POST', '/api/dm/conversations', { token: from.token, body: { user_ids: [to.id] } });++ assert.strictEqual(conv.status, 403, `a DM ${from.username} → ${to.username} (${conv.text})`);++ }++ } finally {++ driver.block('streamer', 'b', false);++ await p.closeAll();++ }++});++t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses accounts that are not named test accounts', async () => {const env = {OV_PARITY_BASE: h.base, OV_PARITY_STREAM: String(streamId), OV_PARITY_SOUND: 'honk',@@@ -189,7 +291,8 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses act('the parity matrix', async () => {const width = Math.max(...results.map((r) => r.key.length));for (const r of results) console.log(` ${r.key.padEnd(width)} ${r.status.toUpperCase().padEnd(4)} ${r.note}`);-- assert.deepStrictEqual(results.filter((r) => r.status === 'gap').map((r) => r.key), ['subonly', 'blocked'], 'the known gaps (see scripts/parity.js)');++ assert.deepStrictEqual(results.map((r) => r.key), parity.SCENARIOS.map((s) => s.key), 'every scenario ran');++ assert.deepStrictEqual(results.filter((r) => r.status !== 'pass').map((r) => `${r.key}: ${r.status}`), [], 'no gap, skip or failure');});t.run(async () => { if (p) await p.closeAll(); if (h) await h.close(); });@@@ -125,7 +125,7 @@ t('joining the channel room without the stream id does not skip slow mode or IPsneaky.sendJson({ type: 'chat', message: 'second within slow mode' });await sneaky.next((m) => m.type === 'system' && /too fast/.test(m.message));assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');-- h.chatServer.slowModeByStream.set(streamX, 0); // (the streamer is inside their own 30s window)++ // (/slow 30 is the channel's saved setting now; the policy set below replaces it)h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };h.ctx.invalidateChannel(chanX);
@@ -23,7 +23,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -23,7 +23,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every
- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every
message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,
message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,
- `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /paste /ai`, `!sr !queue
+ `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /subonly /paste /ai`, `!sr !queue
!np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),
!np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),
DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
`/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"owner": "live",
"owner": "live",
"status": "planned",
"status": "planned",
"visibility": "internal",
"visibility": "internal",
- "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.",
+ "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, active channel subscriptions (sub-only chat), IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.",
"permissions": [
"permissions": [
"live:read"
"live:read"
],
],
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
"GET /internal/chat-context/users",
"GET /internal/chat-context/users",
"POST /internal/chat-context/users/lookup",
"POST /internal/chat-context/users/lookup",
"GET /internal/chat-context/users/:id/follows",
"GET /internal/chat-context/users/:id/follows",
+ "GET /internal/chat-context/subscriber",
"GET /internal/chat-context/users/profile",
"GET /internal/chat-context/users/profile",
"GET /internal/chat-context/streams",
"GET /internal/chat-context/streams",
"GET /internal/chat-context/streams/active",
"GET /internal/chat-context/streams/active",
@@ -15,12 +15,12 @@ bridge and the CLI's own safety checks.
@@ -15,12 +15,12 @@ bridge and the CLI's own safety checks.
| `soundboard` | `!<sound>` gives the room the "played !sound" line and the `soundboard-audio` frame, through the queue | write |
| `soundboard` | `!<sound>` gives the room the "played !sound" line and the `soundboard-audio` frame, through the queue | write |
| `ban` | `/ban`: everyone sees the notice, the banned person's next line is refused and reaches nobody, `/unban` lifts it | destructive |
| `ban` | `/ban`: everyone sees the notice, the banned person's next line is refused and reaches nobody, `/unban` lifts it | destructive |
| `timeout` | `/timeout B 2`: refused inside the two seconds, talking again after | destructive |
| `timeout` | `/timeout B 2`: refused inside the two seconds, talking again after | destructive |
-| `purge` | the dashboard's range purge (ISO `from`/`to`): the room gets the `purge` frame, the range is gone from history, the line before it stays | destructive |
-| `slow` | `/slow 2`: a viewer's second line inside the window is refused and reaches nobody; the moderator's two lines 1.2 s apart both go out | destructive |
-| `subonly` | **gap**: there is no sub-only mode (below) | — |
+| `purge` | the dashboard's range purge (ISO `from`/`to`): the stream's sockets get the `purge` frame, a channel-room popout and the global feed drop the same ids (`delete-messages`), channel and global cursor reads name them in `deleted_ids`; the range is gone from history, the line before it stays | destructive |
+| `slow` | `/slow 2`: a new socket reads the saved value back; a viewer's second line inside the window is refused and reaches nobody; the moderator's two lines 1.2 s apart both go out | destructive |
+| `subonly` | `/subonly`: the room gets the `subonly` frame and a new socket's `auth` says `sub_only`; B (no subscription) and an anonymous viewer are refused and reach nobody; A (an active subscriber) and MOD talk; after `/subonly off` B talks again | destructive |
| `popout` | a live popout (stream join) and a channel-room popout (join by `channelUserId`) read their histories, get the stream's lines, and the channel popout's line reaches the stream; `get-users` answers | write |
| `popout` | a live popout (stream join) and a channel-room popout (join by `channelUserId`) read their histories, get the stream's lines, and the channel popout's line reaches the stream; `get-users` answers | write |
| `reconnect` | A stays, B leaves; a line is deleted and another written while B is away; B reconnects and reads `?after_id=<cursor>`: B's view equals A's, and a fresh page agrees | destructive |
| `reconnect` | A stays, B leaves; a line is deleted and another written while B is away; B reconnects and reads `?after_id=<cursor>`: B's view equals A's, and a fresh page agrees | destructive |
-| `blocked` | **gap**: public chat does not apply blocks (below) | — |
+| `blocked` | A blocked B on the network (one-way): B's lines never reach A's stream socket or global feed; A reconnects and reads `?after_id=` as A: B's lines are left out and the cursor still moves past them; fresh pages read as A (channel, stream, global) leave them out, read as MOD they are there; A's lines reach B; after the unblock B reaches A again | destructive |
## Running it
## Running it
@@ -31,6 +31,7 @@ npm test -- parity # the scenarios against Chat with stubs
@@ -31,6 +31,7 @@ npm test -- parity # the scenarios against Chat with stubs
OV_PARITY_BASE=http://127.0.0.1:4401 OV_PARITY_STREAM=<live stream of a test channel> \
OV_PARITY_BASE=http://127.0.0.1:4401 OV_PARITY_STREAM=<live stream of a test channel> \
OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… OV_PARITY_TOKEN_STREAMER=… \
OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… OV_PARITY_TOKEN_STREAMER=… \
OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer OV_PARITY_SOUND=honk \
OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer OV_PARITY_SOUND=honk \
+OV_PARITY_SUBSCRIBER=a OV_PARITY_A_BLOCKS_B=1 \
node scripts/parity.js [--apply] [--only join,send]
node scripts/parity.js [--apply] [--only join,send]
```
```
@@ -38,6 +39,9 @@ Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MO
@@ -38,6 +39,9 @@ Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MO
stream's channel, STREAMER owns it (needed for `purge` only). `OV_PARITY_SOUND` names a channel sound
stream's channel, STREAMER owns it (needed for `purge` only). `OV_PARITY_SOUND` names a channel sound
command (else `soundboard` is skipped); `OV_PARITY_ORIGIN` (default `https://openvibe.live`) is the
command (else `soundboard` is skipped); `OV_PARITY_ORIGIN` (default `https://openvibe.live`) is the
WebSocket origin; `OV_PARITY_CHANNEL_USER_ID` saves looking up the owner's id.
WebSocket origin; `OV_PARITY_CHANNEL_USER_ID` saves looking up the owner's id.
+`OV_PARITY_SUBSCRIBER=a` says A holds an active Live subscription to the test channel and B none
+(else `subonly` is skipped); `OV_PARITY_A_BLOCKS_B=1` says A has blocked B on openvibe.network (else
+`blocked` is skipped; the test sets the block itself). Both leave the account in that state.
**Safety.** With `--apply` the script first signs each token in and reads the stream's channel
**Safety.** With `--apply` the script first signs each token in and reads the stream's channel
owner, and runs **nothing** (exit 2) unless every one of those accounts is named in
owner, and runs **nothing** (exit 2) unless every one of those accounts is named in
@@ -45,25 +49,46 @@ owner, and runs **nothing** (exit 2) unless every one of those accounts is named
@@ -45,25 +49,46 @@ owner, and runs **nothing** (exit 2) unless every one of those accounts is named
test accounts and their own channel. Do not run it against a real channel. Tokens are never printed.
test accounts and their own channel. Do not run it against a real channel. Tokens are never printed.
Exit 1 when a scenario fails; gaps and skips are reported, not failed.
Exit 1 when a scenario fails; gaps and skips are reported, not failed.
-## Known gaps (2026-09-25)
+## Known gaps (2026-09-26)
-- **Sub-only mode does not exist**, in Chat or in Live: no channel setting, no command, no check
- (followers-only exists). Chat also has no subscriber lookup: subscriptions are Live's. A product
- decision (what "sub" means: Live subscriptions, VIP membership), then a Live context read and a
- rule in `_chatRulesBlock`.
-- **Blocks do not apply to public chat.** `network.block.changed` and `dm_blocks` are honoured for
- DMs and calls only. A blocked person's lines reach the blocker live and in every history read,
- before and after a reconnect, and Live's `chat.js` hides nothing either. Needs a decision (hide
- for the blocker only? both ways? not for moderators?), then per-recipient filtering in the
- broadcasts and the history reads.
- **A channel-room popout's users panel is not the channel's.** `getUserList(null)` is everyone
- **A channel-room popout's users panel is not the channel's.** `getUserList(null)` is everyone
outside a stream: global chat and every offline channel room. The `popout` scenario notes it.
outside a stream: global chat and every offline channel room. The `popout` scenario notes it.
-- **Slow mode lives in memory.** `/slow` is written to the channel's `slow_mode_seconds`, but that
- setting is never read back: a Chat restart turns slow mode off, and the dashboard's value is not
- enforced.
-- `/clear` only clears screens; the lines stay in history and return on a reload. A range purge
- reaches only sockets joined to that stream (not a channel-room popout, not the global feed that
- showed the forwarded copies).
+
+## Closed (2026-09-26)
+
+The gaps the script found on 2026-09-25, with the product decisions taken for them:
+
+- **Sub-only mode.** A channel setting (`channel_moderation_settings.sub_only`, in Live and Chat),
+ set by `/subonly` and `/subonly off` (the streamer, channel moderators and chat staff) and by the
+ dashboard's moderation settings, written where that table's authority says (Live's
+ `channel-settings` effect while it is at `live`, Chat's `upsertChannelModerationSettings` once Chat
+ writes it). When it is on, only people with an **active Live channel subscription** to the streamer
+ (Live's `subscriptions`: status active, the paid period not over; Billing's entitlement under
+ `BILLING_AUTHORITY=billing`), the streamer, channel moderators and chat staff may chat; Network VIP
+ does not count and anonymous viewers cannot chat. Chat asks Live `GET
+ /internal/chat-context/subscriber` (`live-context.js` `isSubscriber`, cached a minute, asked again
+ before a line when unknown or when a "no" is five seconds old) and **fails closed**: when Live
+ cannot be asked, a non-moderator is told sub-only is on. The rule is in `_chatRulesBlock` (chat
+ lines, `/me`, `/tts`); `!` commands follow the same rules as under followers-only (not gated).
+- **Blocks in public chat**, one-way: someone who blocked a person on the network
+ (`network.block.changed`, `network-blocks.js`) no longer gets that person's lines (chat, `/me`,
+ `/tts` and its audio): every broadcast skips their sockets, and history pages and cursor reads
+ (global, channel, stream; and openvibe.chat's first page) leave them out for that reader, with
+ `latest_id` and `deleted_ids` unchanged so the cursor moves on. Everyone else, the blocked person
+ included, is unaffected; moderation logs and exports show everything; DMs and calls keep their rule
+ (blocked either way). Chat's own messenger blocks (`dm_blocks`) stay DM-only.
+- **Slow mode is the saved `slow_mode_seconds`.** `/slow N` and `/slow off` write it (as above) and
+ the room hears it once saved; Chat reads it back from the channel's policy (at join, after its own
+ write, and when Live says the channel changed), so a restart keeps slow mode and the dashboard's
+ value is enforced; a change made in the dashboard is announced to the room. Live's dashboard sent
+ and read `slowmode_seconds`, which no writer knew, so its slow mode was never saved; it uses
+ `slow_mode_seconds` now (the old name is still accepted). Moderators stay exempt.
+- **Purges and deletes reach every surface.** A range purge sends the `purge` frame to the stream's
+ sockets and the purged ids (`delete-messages`) to the whole channel room (other slots, the offline
+ room, a channel popout) and the global feed; single deletes (Chat's own and Live's dashboard) reach
+ the channel room and the global feed too. Cursor reads name them in `deleted_ids`.
+- **`/clear` clears screens** (Twitch semantics) and now says so to the moderator: "Chat cleared on
+ screen; messages stay in history — use purge to remove them."
## Fixed while building it
## Fixed while building it
@@ -9,7 +9,7 @@ their authority to Chat **one table at a time**, each move reversible:
@@ -9,7 +9,7 @@ their authority to Chat **one table at a time**, each move reversible:
| `chat_ai_summaries` | `server/ai/chat-ai.js` (global, user, relay-user, anon insights) | Live's chat AI calls Chat's `upsertChatAiSummary` |
| `chat_ai_summaries` | `server/ai/chat-ai.js` (global, user, relay-user, anon insights) | Live's chat AI calls Chat's `upsertChatAiSummary` |
| `user_tags` | none (chat tags are read-only in Live since the game moved to OpenVibe.Games) | data only; Chat has `grantUserTag` / `revokeUserTag` |
| `user_tags` | none (chat tags are read-only in Live since the game moved to OpenVibe.Games) | data only; Chat has `grantUserTag` / `revokeUserTag` |
| `emotes` | `POST/PATCH/DELETE /api/emotes`, Media asset-sync (`media_url`, `media_asset_id`) | Live's routes and asset-sync call Chat's `createEmote`, `updateEmote`, `deleteEmote`, `setEmoteMedia` |
| `emotes` | `POST/PATCH/DELETE /api/emotes`, Media asset-sync (`media_url`, `media_asset_id`) | Live's routes and asset-sync call Chat's `createEmote`, `updateEmote`, `deleteEmote`, `setEmoteMedia` |
-| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow` and alert sounds are written by Chat itself |
+| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow`, `/subonly` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow`, `/subonly` and alert sounds are written by Chat itself |
| `channel_moderators` | `POST/DELETE /api/channels/:id/mods` | Live's routes call Chat's `addChannelModerator` / `removeChannelModerator` |
| `channel_moderators` | `POST/DELETE /api/channels/:id/mods` | Live's routes call Chat's `addChannelModerator` / `removeChannelModerator` |
`media_requests` is **not** part of this: it is not a chat table. Its writers are Live's media
`media_requests` is **not** part of this: it is not a chat table. Its writers are Live's media
@@ -58,7 +58,7 @@ at once. Live's table is from then on a read mirror: Chat's triggers queue every
@@ -58,7 +58,7 @@ at once. Live's table is from then on a read mirror: Chat's triggers queue every
in `live_mirror_outbox` and the mirror sends it to Live (`/internal/chat-effects/mirror`, as for
in `live_mirror_outbox` and the mirror sends it to Live (`/internal/chat-effects/mirror`, as for
Chat's own tables), which takes staged rows only for a table Chat writes. Live's readers do not
Chat's own tables), which takes staged rows only for a table Chat writes. Live's readers do not
change. Chat's chat server reads `channel_moderators` and `channel_moderation_settings` in place,
change. Chat's chat server reads `channel_moderators` and `channel_moderation_settings` in place,
-and writes `/slow` and alert sounds itself.
+and writes `/slow`, `/subonly` and alert sounds itself.
**The flip back** (`{"authority":"live"}`): Chat sends Live every queued change of the table and
**The flip back** (`{"authority":"live"}`): Chat sends Live every queued change of the table and
refuses to give it back while any is left; then Live records `live` and captures again. Running a
refuses to give it back while any is left; then Live records `live` and captures again. Running a
@@ -13,10 +13,13 @@
@@ -13,10 +13,13 @@
* OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… [OV_PARITY_TOKEN_STREAMER=…] \
* OV_PARITY_TOKEN_A=… OV_PARITY_TOKEN_B=… OV_PARITY_TOKEN_MOD=… [OV_PARITY_TOKEN_STREAMER=…] \
* OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer \
* OV_PARITY_TEST_ACCOUNTS=parity_a,parity_b,parity_mod,parity_streamer \
* [OV_PARITY_SOUND=honk] [OV_PARITY_ORIGIN=https://openvibe.live] [OV_PARITY_CHANNEL_USER_ID=…] \
* [OV_PARITY_SOUND=honk] [OV_PARITY_ORIGIN=https://openvibe.live] [OV_PARITY_CHANNEL_USER_ID=…] \
+ * [OV_PARITY_SUBSCRIBER=a] [OV_PARITY_A_BLOCKS_B=1] \
* node scripts/parity.js [--apply] [--only join,send,…]
* node scripts/parity.js [--apply] [--only join,send,…]
*
*
* Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MOD moderates the
* Roles: A and B are viewers (B is the one banned, timed out and reconnecting), MOD moderates the
* stream's channel (a channel moderator or its owner), STREAMER owns it (the purge; optional).
* stream's channel (a channel moderator or its owner), STREAMER owns it (the purge; optional).
+ * OV_PARITY_SUBSCRIBER=a: A holds an active Live subscription to the test channel and B does not
+ * (the subonly scenario). OV_PARITY_A_BLOCKS_B=1: A has blocked B on openvibe.network (blocked).
*
*
* Without --apply this is a dry run: it prints what each scenario would do and as whom, and opens
* Without --apply this is a dry run: it prints what each scenario would do and as whom, and opens
* no connection. With --apply it first signs each token in (one WebSocket join each) and reads the
* no connection. With --apply it first signs each token in (one WebSocket join each) and reads the
@@ -24,6 +27,7 @@
@@ -24,6 +27,7 @@
* OV_PARITY_TEST_ACCOUNTS: the messages, DMs, bans, timeouts, slow mode and purges then only ever
* OV_PARITY_TEST_ACCOUNTS: the messages, DMs, bans, timeouts, slow mode and purges then only ever
* touch dedicated test accounts and their own channel. Never point --apply at a real channel.
* touch dedicated test accounts and their own channel. Never point --apply at a real channel.
* Tokens are never printed. Exit 0 = no scenario failed (gaps and skips are reported, not failed).
* Tokens are never printed. Exit 0 = no scenario failed (gaps and skips are reported, not failed).
+ * A driver may offer block(blocker, blocked, active) to set a network block itself (the test does).
*/
*/
'use strict';
'use strict';
@@ -53,8 +57,10 @@ const lower = (s) => String(s || '').toLowerCase();
@@ -53,8 +57,10 @@ const lower = (s) => String(s || '').toLowerCase();
* }
* }
*/
*/
class Parity {
class Parity {
- constructor({ driver, tokens, streamId, channelUserId = null, sound = null, testAccounts = [], log = () => {} }) {
+ constructor({ driver, tokens, streamId, channelUserId = null, sound = null, testAccounts = [], subscriber = null, aBlocksB = false, log = () => {} }) {
this.driver = driver;
this.driver = driver;
+ this.subscriber = subscriber ? lower(subscriber) : null;
+ this.aBlocksB = !!aBlocksB;
this.tokens = tokens;
this.tokens = tokens;
this.streamId = Number(streamId);
this.streamId = Number(streamId);
this.ownerId = channelUserId ? Number(channelUserId) : null;
this.ownerId = channelUserId ? Number(channelUserId) : null;
@@ -356,9 +362,12 @@ const SCENARIOS = [
@@ -356,9 +362,12 @@ const SCENARIOS = [
},
},
{
{
key: 'purge', kind: 'destructive', as: ['streamer', 'a', 'b'], needs: ['a', 'b', 'streamer'],
key: 'purge', kind: 'destructive', as: ['streamer', 'a', 'b'], needs: ['a', 'b', 'streamer'],
- does: 'STREAMER purges a time range of the stream (the dashboard’s ISO range): A and B get the purge frame; the lines in the range are gone from history, the one before stays',
+ does: 'STREAMER purges a time range of the stream (the dashboard’s ISO range): A and B get the purge frame; a channel popout and the global feed drop the same ids; the range is gone from history (cursor reads name it in deleted_ids), the line before stays',
async run(p) {
async run(p) {
const a = await p.join('a'); const b = await p.join('b');
const a = await p.join('a'); const b = await p.join('b');
+ // Other surfaces that show the stream's lines: a channel-room popout and the global feed.
+ const pop = await p.join('a', { stream: false, channel: true });
+ const feed = await p.join('a', { stream: false });
const before = p.text('before the purge');
const before = p.text('before the purge');
await p.say(a, before);
await p.say(a, before);
const kept = await p.saw(b, before);
const kept = await p.saw(b, before);
@@ -379,10 +388,17 @@ const SCENARIOS = [
@@ -379,10 +388,17 @@ const SCENARIOS = [
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.ok(r.body.deleted >= 2, `purged ${r.body.deleted}`);
assert.ok(r.body.deleted >= 2, `purged ${r.body.deleted}`);
for (const ws of [a, b]) await ws.next((m) => m.type === 'purge' && m.from === from && m.to === to);
for (const ws of [a, b]) await ws.next((m) => m.type === 'purge' && m.from === from && m.to === to);
+ const both = (m) => m.type === 'delete-messages' && [m1.id, m2.id].every((id) => (m.ids || []).includes(id));
+ await pop.next(both);
+ await feed.next(both);
const ids = (await p.http('GET', `/api/chat/${p.streamId}/history?limit=200`)).body.messages.map((m) => m.id);
const ids = (await p.http('GET', `/api/chat/${p.streamId}/history?limit=200`)).body.messages.map((m) => m.id);
assert.ok(!ids.includes(m1.id) && !ids.includes(m2.id), 'the range is gone from history');
assert.ok(!ids.includes(m1.id) && !ids.includes(m2.id), 'the range is gone from history');
assert.ok(ids.includes(kept.id), 'the line before the range stays');
assert.ok(ids.includes(kept.id), 'the line before the range stays');
- return `${r.body.deleted} purged`;
+ for (const room of [`/api/chat/channel/${p.ownerId}/history`, '/api/chat/global/history']) {
+ const d = (await p.http('GET', `${room}?after_id=${m2.id}`)).body;
+ assert.ok([m1.id, m2.id].every((id) => (d.deleted_ids || []).includes(id)), `${room}: a cursor read names the purged ids`);
+ }
+ return `${r.body.deleted} purged; the popout and the global feed dropped them too`;
},
},
},
},
{
{
@@ -395,6 +411,8 @@ const SCENARIOS = [
@@ -395,6 +411,8 @@ const SCENARIOS = [
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, secs);
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, secs);
await p.system(a, `Slow mode enabled: ${secs}s between messages`);
await p.system(a, `Slow mode enabled: ${secs}s between messages`);
try {
try {
+ // The saved setting is what chat enforces: a new socket reads it back.
+ assert.strictEqual((await p.join('b')).auth.slowmode_seconds, secs, 'the saved slow mode is read back');
// Everyone may share one address (the CLI): let the /slow line's window pass first.
// Everyone may share one address (the CLI): let the /slow line's window pass first.
await p.sleep(secs * 1000 + 200);
await p.sleep(secs * 1000 + 200);
const one = p.text('slow one'); const two = p.text('slow two');
const one = p.text('slow one'); const two = p.text('slow two');
@@ -423,10 +441,32 @@ const SCENARIOS = [
@@ -423,10 +441,32 @@ const SCENARIOS = [
},
},
},
},
{
{
- key: 'subonly', kind: 'gap', as: [], needs: [],
- does: 'sub-only mode: a non-subscriber is refused, a subscriber may talk',
+ key: 'subonly', kind: 'destructive', as: ['mod', 'a', 'b', 'anon'], needs: ['a', 'b', 'mod'],
+ does: 'MOD /subonly: B (no subscription) and an anonymous viewer are refused and reach nobody; A (an active subscriber) and MOD talk; a new socket is told; /subonly off lets B talk again',
async run(p) {
async run(p) {
- p.gap('no sub-only mode exists: no channel setting, no command and no check in Chat or Live (followers-only exists), and Chat has no subscriber lookup in live-context (subscriptions are Live’s)');
+ if (p.subscriber !== 'a') p.skip('needs A to hold an active subscription to the test channel and B none (then OV_PARITY_SUBSCRIBER=a)');
+ const a = await p.join('a'); const b = await p.join('b'); const mod = await p.join('mod'); const anon = await p.join('anon');
+ const refused = (m) => m.type === 'system' && /^This chat is in sub-only mode/.test(m.message);
+ await p.say(mod, '/subonly');
+ assert.strictEqual((await b.next((m) => m.type === 'subonly')).enabled, true);
+ await p.system(b, 'Sub-only mode enabled: only subscribers and moderators can chat.');
+ try {
+ assert.strictEqual((await p.join('b')).auth.sub_only, true, 'a new socket is told (the saved setting)');
+ const nb = p.text('B has no subscription');
+ await p.say(b, nb);
+ assert.match((await b.next(refused)).message, /only subscribers/);
+ const na = p.text('anonymous in sub-only');
+ await p.say(anon, na);
+ assert.match((await anon.next(refused)).message, /sign in and subscribe/);
+ assert.ok(await a.none((m) => m.type === 'chat' && (m.message === nb || m.message === na), QUIET_MS), 'refused lines reach nobody');
+ const ya = p.text('A subscribes'); await p.say(a, ya); await p.saw(b, ya);
+ const ym = p.text('MOD is exempt'); await p.say(mod, ym); await p.saw(b, ym);
+ } finally {
+ await p.say(mod, '/subonly off');
+ await p.system(a, 'Sub-only mode disabled.');
+ }
+ const back = p.text('B after sub-only'); await p.say(b, back); await p.saw(a, back);
+ return 'non-subscriber and anonymous refused; subscriber and moderator talk';
},
},
},
},
{
{
@@ -497,10 +537,53 @@ const SCENARIOS = [
@@ -497,10 +537,53 @@ const SCENARIOS = [
},
},
},
},
{
{
- key: 'blocked', kind: 'gap', as: [], needs: [],
- does: 'reconnect convergence for blocked messages: lines from someone the reader blocked (network blocks) are hidden live and after a reconnect',
+ key: 'blocked', kind: 'destructive', as: ['a', 'b', 'mod'], needs: ['a', 'b', 'mod'],
+ does: 'A blocked B on the network (one-way): B’s lines never reach A, live or after A reconnects (?after_id= and a fresh page, read as A: channel, stream and global); MOD sees them; A’s lines still reach B',
async run(p) {
async run(p) {
- p.gap('public chat does not apply blocks: network.block.changed and dm_blocks are honoured for DMs and calls only (server/chat/network-blocks.js, dm.js isBlockedEither); room broadcasts and history pages show a blocked person’s lines to the blocker, live and after a reconnect alike, and Live’s chat.js hides nothing either');
+ if (p.driver.block) await p.driver.block('a', 'b', true);
+ else if (!p.aBlocksB) p.skip('needs A to have blocked B on openvibe.network (then OV_PARITY_A_BLOCKS_B=1)');
+ try {
+ const room = `/api/chat/channel/${p.ownerId}/history`;
+ let a = await p.join('a'); const b = await p.join('b'); const mod = await p.join('mod');
+ const feed = await p.join('a', { stream: false });
+ const viewA = new RoomView((await p.http('GET', `${room}?limit=500`, { as: 'a' })).body); const fromA = a.all.length;
+ const t1 = p.text('B speaks (A blocked B)');
+ await p.say(b, t1);
+ const m1 = await p.saw(mod, t1);
+ const isB = (m) => m.type === 'chat' && m.user_id === p.users.b.id;
+ assert.ok(await a.none(isB, QUIET_MS), 'live: B’s line does not reach A');
+ assert.ok(await feed.none(isB, 50), 'nor A’s global feed');
+ const t2 = p.text('A still reaches B');
+ await p.say(a, t2);
+ await p.saw(b, t2);
+ viewA.frames(a.all.slice(fromA));
+ await p.left(a);
+ const t3 = p.text('B while A is away');
+ await p.say(b, t3);
+ const m3 = await p.saw(mod, t3);
+ a = await p.join('a');
+ const d = await p.http('GET', `${room}?after_id=${viewA.cursor}`, { as: 'a' });
+ assert.strictEqual(d.status, 200);
+ viewA.delta(d.body);
+ assert.ok(!viewA.ids.has(m1.id) && !viewA.ids.has(m3.id), 'A’s cursor read leaves B’s lines out');
+ assert.ok(viewA.cursor >= m3.id, 'the cursor still moves past them');
+ const hidden = [m1.id, m3.id];
+ for (const path of [room, `/api/chat/${p.streamId}/history`, '/api/chat/global/history']) {
+ const asA = (await p.http('GET', `${path}?limit=500`, { as: 'a' })).body.messages.map((m) => m.id);
+ assert.ok(!hidden.some((id) => asA.includes(id)), `${path}: a fresh page read as A`);
+ const asMod = (await p.http('GET', `${path}?limit=500`, { as: 'mod' })).body.messages.map((m) => m.id);
+ assert.ok(hidden.every((id) => asMod.includes(id)), `${path}: everyone else still reads them`);
+ }
+ } finally {
+ if (p.driver.block) await p.driver.block('a', 'b', false);
+ }
+ if (!p.driver.block) return 'hidden from A live and after a reconnect';
+ // Unblocked: B reaches A again.
+ const a = await p.join('a'); const b = await p.join('b');
+ const t4 = p.text('B after the unblock');
+ await p.say(b, t4);
+ await p.saw(a, t4);
+ return 'hidden from A live and after a reconnect; back after the unblock';
},
},
},
},
];
];
@@ -578,6 +661,8 @@ function configFrom(env) {
@@ -578,6 +661,8 @@ function configFrom(env) {
sound: env.OV_PARITY_SOUND || null,
sound: env.OV_PARITY_SOUND || null,
tokens: { a: env.OV_PARITY_TOKEN_A || null, b: env.OV_PARITY_TOKEN_B || null, mod: env.OV_PARITY_TOKEN_MOD || null, streamer: env.OV_PARITY_TOKEN_STREAMER || null },
tokens: { a: env.OV_PARITY_TOKEN_A || null, b: env.OV_PARITY_TOKEN_B || null, mod: env.OV_PARITY_TOKEN_MOD || null, streamer: env.OV_PARITY_TOKEN_STREAMER || null },
testAccounts: String(env.OV_PARITY_TEST_ACCOUNTS || '').split(',').map((s) => s.trim()).filter(Boolean),
testAccounts: String(env.OV_PARITY_TEST_ACCOUNTS || '').split(',').map((s) => s.trim()).filter(Boolean),
+ subscriber: env.OV_PARITY_SUBSCRIBER ? String(env.OV_PARITY_SUBSCRIBER).trim().toLowerCase() : null,
+ aBlocksB: env.OV_PARITY_A_BLOCKS_B === '1',
};
};
}
}
@@ -587,7 +672,7 @@ const LABEL = { pass: 'PASS', gap: 'GAP ', skip: 'SKIP', fail: 'FAIL' };
@@ -587,7 +672,7 @@ const LABEL = { pass: 'PASS', gap: 'GAP ', skip: 'SKIP', fail: 'FAIL' };
async function main(argv = process.argv.slice(2), env = process.env, log = console.log, { driver = null } = {}) {
async function main(argv = process.argv.slice(2), env = process.env, log = console.log, { driver = null } = {}) {
let o;
let o;
try { o = parseArgs(argv); } catch (err) { log(err.message); return 2; }
try { o = parseArgs(argv); } catch (err) { log(err.message); return 2; }
- if (o.help) { log(require('fs').readFileSync(__filename, 'utf8').split('\n').slice(1, 32).join('\n')); return 0; }
+ if (o.help) { log(require('fs').readFileSync(__filename, 'utf8').split('\n').slice(1, 30).join('\n')); return 0; }
const cfg = configFrom(env);
const cfg = configFrom(env);
const chosen = SCENARIOS.filter((s) => !o.only || o.only.includes(s.key));
const chosen = SCENARIOS.filter((s) => !o.only || o.only.includes(s.key));
if (o.only && chosen.length !== o.only.length) { log(`unknown scenario in --only (known: ${SCENARIOS.map((s) => s.key).join(', ')})`); return 2; }
if (o.only && chosen.length !== o.only.length) { log(`unknown scenario in --only (known: ${SCENARIOS.map((s) => s.key).join(', ')})`); return 2; }
@@ -607,7 +692,9 @@ async function main(argv = process.argv.slice(2), env = process.env, log = conso
@@ -607,7 +692,9 @@ async function main(argv = process.argv.slice(2), env = process.env, log = conso
const missing = (s.needs || []).filter((r) => !cfg.tokens[r]);
const missing = (s.needs || []).filter((r) => !cfg.tokens[r]);
const would = s.kind === 'gap' ? 'reports a gap, touches nothing'
const would = s.kind === 'gap' ? 'reports a gap, touches nothing'
: missing.length ? `would skip: needs ${missing.map((r) => `OV_PARITY_TOKEN_${r.toUpperCase()}`).join(', ')}`
: missing.length ? `would skip: needs ${missing.map((r) => `OV_PARITY_TOKEN_${r.toUpperCase()}`).join(', ')}`
- : s.key === 'soundboard' && !cfg.sound ? 'would skip: needs OV_PARITY_SOUND' : `as ${s.as.join(', ')}`;
+ : s.key === 'soundboard' && !cfg.sound ? 'would skip: needs OV_PARITY_SOUND'
+ : s.key === 'subonly' && cfg.subscriber !== 'a' ? 'would skip: needs OV_PARITY_SUBSCRIBER=a'
+ : s.key === 'blocked' && !cfg.aBlocksB ? 'would skip: needs OV_PARITY_A_BLOCKS_B=1' : `as ${s.as.join(', ')}`;
log(` ${s.key.padEnd(11)} ${s.kind.padEnd(11)} ${would}`);
log(` ${s.key.padEnd(11)} ${s.kind.padEnd(11)} ${would}`);
log(` ${s.does}`);
log(` ${s.does}`);
}
}
@@ -618,7 +705,7 @@ async function main(argv = process.argv.slice(2), env = process.env, log = conso
@@ -618,7 +705,7 @@ async function main(argv = process.argv.slice(2), env = process.env, log = conso
}
}
if (problems.length) { for (const p of problems) log(p); return 2; }
if (problems.length) { for (const p of problems) log(p); return 2; }
- const p = new Parity({ driver: driver || networkDriver(cfg), tokens: cfg.tokens, streamId: cfg.streamId, channelUserId: cfg.channelUserId, sound: cfg.sound, testAccounts: cfg.testAccounts, log });
+ const p = new Parity({ driver: driver || networkDriver(cfg), tokens: cfg.tokens, streamId: cfg.streamId, channelUserId: cfg.channelUserId, sound: cfg.sound, testAccounts: cfg.testAccounts, subscriber: cfg.subscriber, aBlocksB: cfg.aBlocksB, log });
try { await p.resolve(); } catch (err) { log(`could not resolve the accounts and the stream: ${err.message}`); return 2; }
try { await p.resolve(); } catch (err) { log(`could not resolve the accounts and the stream: ${err.message}`); return 2; }
const outside = p.untested();
const outside = p.untested();
if (outside.length) {
if (outside.length) {
@@ -31,6 +31,7 @@ const ttsEngine = require('./tts-engine');
@@ -31,6 +31,7 @@ const ttsEngine = require('./tts-engine');
const soundboard = require('./soundboard-service');
const soundboard = require('./soundboard-service');
const audioQueue = require('./audio-queue');
const audioQueue = require('./audio-queue');
const dm = require('./dm');
const dm = require('./dm');
+const networkBlocks = require('./network-blocks');
const vipBadges = require('../vip/badges');
const vipBadges = require('../vip/badges');
// Cosmetics and tags are Live's (monetization/cosmetics, game/tags); read through live-context.
// Cosmetics and tags are Live's (monetization/cosmetics, game/tags); read through live-context.
const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
@@ -52,6 +53,11 @@ const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
@@ -52,6 +53,11 @@ const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';
const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';
+// Sub-only mode (WS-I task 6): who may chat is decided in _chatRulesBlock.
+const SUB_ONLY_REFUSED = 'This chat is in sub-only mode: only subscribers of this channel can chat.';
+const SUB_ONLY_ANON = 'This chat is in sub-only mode: sign in and subscribe to chat.';
+// A person's own lines: what someone who blocked them on the network no longer gets (network-blocks.js).
+const LINE_FRAMES = new Set(['chat', 'tts', 'tts-audio']);
const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
// Built-in slur categories and normalization are defined in moderation-utils.js
// Built-in slur categories and normalization are defined in moderation-utils.js
// (single source of truth — browser-side chat.js mirrors the same patterns).
// (single source of truth — browser-side chat.js mirrors the same patterns).
@@ -79,8 +85,13 @@ class ChatServer {
@@ -79,8 +85,13 @@ class ChatServer {
/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
/** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
this.rateLimits = new Map();
this.rateLimits = new Map();
this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
- /** @type {Map<number, number>} streamId → slow mode ms (0 = off, default rate limit applies) */
- this.slowModeByStream = new Map();
+ // Slow mode and sub-only mode are the channel's saved settings (slowModeMs, _chatRulesBlock);
+ // channelId → { slow, sub } last announced to its room (_channelSettingsSeen).
+ this._announcedModes = new Map();
+ this._modeWrites = new Map(); // channelId → /slow or /subonly writes running
+ /** @type {WeakMap<object, Set<string>|null>} frame → the subjects who blocked its author (_blockersOf) */
+ this._blockMemo = new WeakMap();
+ ctx.onChannelSettings((channelId, settings) => this._channelSettingsSeen(channelId, settings));
this.heartbeatInterval = null;
this.heartbeatInterval = null;
// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time.
// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time.
/** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
/** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
@@ -383,7 +394,7 @@ class ChatServer {
@@ -383,7 +394,7 @@ class ChatServer {
// Slow mode of the stream that governs the room (an offline/channel-room join has no
// Slow mode of the stream that governs the room (an offline/channel-room join has no
// stream id of its own but posts into the live room).
// stream id of its own but posts into the live room).
const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
- const streamSlowMs = (slowStreamId && this.slowModeByStream.get(slowStreamId)) || 0;
+ const streamSlowMs = this.slowModeMs(slowStreamId);
// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep
// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep
// the flood limit only, so they can moderate and turn slow mode off again at once.
// the flood limit only, so they can moderate and turn slow mode off again at once.
const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);
const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);
@@ -397,6 +408,7 @@ class ChatServer {
@@ -397,6 +408,7 @@ class ChatServer {
switch (msg.type) {
switch (msg.type) {
case 'chat':
case 'chat':
+ await this._warmSubOnly(client).catch(() => {});
this.handleChatMessage(ws, client, msg);
this.handleChatMessage(ws, client, msg);
break;
break;
case 'self-delete-history':
case 'self-delete-history':
@@ -456,7 +468,8 @@ class ChatServer {
@@ -456,7 +468,8 @@ class ChatServer {
setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
// Send identity confirmation so the client knows who it is
// Send identity confirmation so the client knows who it is
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
- const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;
+ const roomStreamId = client.streamId || this._moderationStreamFor(client);
+ const streamSlowSec = Math.round(this.slowModeMs(roomStreamId) / 1000);
const streamSettings = this._getChannelChatSettings(client.streamId);
const streamSettings = this._getChannelChatSettings(client.streamId);
this.sendTo(ws, {
this.sendTo(ws, {
type: 'auth',
type: 'auth',
@@ -467,6 +480,7 @@ class ChatServer {
@@ -467,6 +480,7 @@ class ChatServer {
role: client.user ? client.user.role : 'anon',
role: client.user ? client.user.role : 'anon',
user_id: client.user?.id || null,
user_id: client.user?.id || null,
slowmode_seconds: streamSlowSec,
slowmode_seconds: streamSlowSec,
+ sub_only: !!(roomStreamId && this._getChannelChatSettings(roomStreamId).sub_only),
allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
@@ -1073,6 +1087,18 @@ class ChatServer {
@@ -1073,6 +1087,18 @@ class ChatServer {
return true;
return true;
}
}
+ // Sub-only: people with an active subscription to this channel (Live's subscriptions; Network
+ // VIP does not count), the streamer, channel moderators and chat staff. Unknown (Live not
+ // asked yet, or unreachable) counts as not subscribed; _warmSubOnly asked before this line.
+ if (chatSettings.sub_only && !isStaff && !canModerateThisStream) {
+ const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;
+ const allowed = !!(client.user && ownerId && (Number(ownerId) === Number(client.user.id) || ctx.isSubscriber(client.user.id, ownerId)));
+ if (!allowed) {
+ this.sendTo(ws, { type: 'system', message: client.user ? SUB_ONLY_REFUSED : SUB_ONLY_ANON });
+ return true;
+ }
+ }
+
// Links disabled — exempt [gif:url] tags (validated separately)
// Links disabled — exempt [gif:url] tags (validated separately)
if (chatSettings.links_allowed === 0 && !isStaff) {
if (chatSettings.links_allowed === 0 && !isStaff) {
const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
@@ -1152,6 +1178,22 @@ class ChatServer {
@@ -1152,6 +1178,22 @@ class ChatServer {
return true;
return true;
}
}
+ /**
+ * Before a line is judged in a sub-only room: have the sender's subscription answer. Asks Live
+ * (briefly) when there is none yet or when a cached "no" is a few seconds old (they may have just
+ * subscribed); a "yes" is served from the cache. Nothing is asked outside sub-only rooms.
+ */
+ async _warmSubOnly(client) {
+ if (!client || !client.user) return;
+ const modStreamId = client.streamId || this._moderationStreamFor(client);
+ if (!modStreamId || !this._getChannelChatSettings(modStreamId).sub_only) return;
+ if (permissions.canModerateStream(client.user, modStreamId)) return;
+ const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;
+ if (!ownerId || Number(ownerId) === Number(client.user.id)) return;
+ const state = ctx.subscriberState(client.user.id, ownerId);
+ if (state !== true) await ctx.ensureSubscriber(client.user.id, ownerId, { maxAgeMs: state === false ? 5000 : 0 });
+ }
+
/** /me and /tts put text in the room: the same rules as a chat line. */
/** /me and /tts put text in the room: the same rules as a chat line. */
_commandTextBlocked(ws, client, text) {
_commandTextBlocked(ws, client, text) {
const modStreamId = client.streamId || this._moderationStreamFor(client);
const modStreamId = client.streamId || this._moderationStreamFor(client);
@@ -1542,7 +1584,7 @@ class ChatServer {
@@ -1542,7 +1584,7 @@ class ChatServer {
message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
(this.canModerate(client)
(this.canModerate(client)
- ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear, /slow <seconds>, /skiptts [id], /cleartts`
+ ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
: ''),
: ''),
});
});
break;
break;
@@ -1558,6 +1600,7 @@ class ChatServer {
@@ -1558,6 +1600,7 @@ class ChatServer {
type: 'tts',
type: 'tts',
username: client.user?.display_name || client.anonId,
username: client.user?.display_name || client.anonId,
core_username: client.user?.username || null,
core_username: client.user?.username || null,
+ user_id: client.user?.id || null,
message: args,
message: args,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
};
};
@@ -1651,6 +1694,7 @@ class ChatServer {
@@ -1651,6 +1694,7 @@ class ChatServer {
type: 'chat',
type: 'chat',
username,
username,
core_username: client.user?.username || null,
core_username: client.user?.username || null,
+ user_id: client.user?.id || null,
role: client.user?.role || 'anon',
role: client.user?.role || 'anon',
message: `* ${username} ${args}`,
message: `* ${username} ${args}`,
is_action: true,
is_action: true,
@@ -1694,8 +1738,11 @@ class ChatServer {
@@ -1694,8 +1738,11 @@ class ChatServer {
}
}
case 'clear':
case 'clear':
+ // Twitch semantics: every screen in the room is cleared, nothing is deleted. Removing
+ // lines for good is a purge (dashboard) or a delete.
if (this.canModerate(client)) {
if (this.canModerate(client)) {
this._broadcastToRoom(client, { type: 'clear' });
this._broadcastToRoom(client, { type: 'clear' });
+ this.sendTo(ws, { type: 'system', message: 'Chat cleared on screen; messages stay in history — use purge to remove them.' });
this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
} else {
} else {
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
@@ -1703,41 +1750,25 @@ class ChatServer {
@@ -1703,41 +1750,25 @@ class ChatServer {
break;
break;
case 'slow': {
case 'slow': {
- if (this.canModerate(client)) {
- let seconds;
- if (args === 'off' || args === 'disable' || args === '0') {
- seconds = 0;
- } else {
- seconds = parseInt(args);
- if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
- }
- // Per-stream slow mode (not global)
- if (client.streamId) {
- this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);
- // Persist (by Live, or here once Chat writes channel_moderation_settings: live-context effects)
- try {
- const stream = ctx.getStreamById(client.streamId);
- if (stream?.channel_id) {
- ctx.effects.updateChannelModerationSettings(stream.channel_id, { slow_mode_seconds: seconds }, client.user?.id || null).catch(() => { /* non-critical */ });
- }
- } catch { /* non-critical */ }
- }
- // Dedicated slowmode event so clients can show/hide UI
- this._broadcastToRoom(client, {
- type: 'slowmode',
- seconds,
- });
- const msg = seconds > 0
- ? `Slow mode enabled: ${seconds}s between messages`
- : 'Slow mode disabled.';
- this._broadcastToRoom(client, {
- type: 'system',
- message: msg,
- });
- this.logChatModeration(client, 'slowmode_update', { seconds });
+ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
+ let seconds;
+ if (args === 'off' || args === 'disable' || args === '0') {
+ seconds = 0;
} else {
} else {
- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
+ seconds = parseInt(args);
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
}
}
+ this._setChannelModes(ws, client, { slow_mode_seconds: seconds }, 'slow mode')
+ .then((ok) => { if (ok) this.logChatModeration(client, 'slowmode_update', { seconds }); });
+ break;
+ }
+
+ case 'subonly': {
+ // /subonly [on] · /subonly off — the streamer, channel mods and chat staff.
+ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
+ const off = ['off', 'disable', '0', 'false', 'no'].includes(String(args || '').trim().toLowerCase());
+ this._setChannelModes(ws, client, { sub_only: off ? 0 : 1 }, 'sub-only mode')
+ .then((ok) => { if (ok) this.logChatModeration(client, 'subonly_update', { enabled: !off }); });
break;
break;
}
}
@@ -2296,6 +2327,33 @@ class ChatServer {
@@ -2296,6 +2327,33 @@ class ChatServer {
}).catch(() => { /* best-effort */ });
}).catch(() => { /* best-effort */ });
}
}
+ /**
+ * Public chat and blocks (network-blocks.js, one-way): the subjects who blocked the author of a
+ * line frame (chat, /me, /tts and its audio), or null — the usual case, everyone gets it.
+ * Computed once per frame object, however many rooms it goes to.
+ */
+ _blockersOf(data) {
+ if (!data || typeof data !== 'object' || !LINE_FRAMES.has(data.type)) return null;
+ if (this._blockMemo.has(data)) return this._blockMemo.get(data);
+ let out = null;
+ try {
+ let authorId = data.user_id || null;
+ if (!authorId && data.type === 'tts-audio' && /^user:/.test(String(data.sender_key || ''))) {
+ authorId = ctx.getUserByUsername(String(data.sender_key).slice(5))?.id || null;
+ }
+ const subject = authorId ? db.subjectFor(authorId) : null;
+ const list = subject ? networkBlocks.blockersOf(subject) : [];
+ if (list.length) out = new Set(list);
+ } catch { out = null; }
+ this._blockMemo.set(data, out);
+ return out;
+ }
+
+ /** Does this socket's person not get the frame (they blocked its author)? */
+ _blockedFor(client, blockers) {
+ return !!(blockers && client.user && blockers.has(this._subjectOfUser(client.user)));
+ }
+
/**
/**
* Deliver to the room a client is in: its stream room when in a stream, its channel room when
* Deliver to the room a client is in: its stream room when in a stream, its channel room when
* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every
* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every
@@ -2309,8 +2367,9 @@ class ChatServer {
@@ -2309,8 +2367,9 @@ class ChatServer {
broadcastToStream(streamId, data) {
broadcastToStream(streamId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2324,8 +2383,10 @@ class ChatServer {
@@ -2324,8 +2383,10 @@ class ChatServer {
*/
*/
broadcastToChannelRoom(channelUserId, streamId, data) {
broadcastToChannelRoom(channelUserId, streamId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
+ if (this._blockedFor(client, blockers)) continue;
const inChannel = channelUserId && client.channelUserId === channelUserId;
const inChannel = channelUserId && client.channelUserId === channelUserId;
const inStream = streamId && client.streamId === streamId;
const inStream = streamId && client.streamId === streamId;
if (inChannel || inStream) ws.send(msg);
if (inChannel || inStream) ws.send(msg);
@@ -2343,8 +2404,9 @@ class ChatServer {
@@ -2343,8 +2404,9 @@ class ChatServer {
try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }
try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }
if (!username) return;
if (!username) return;
const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
ws.send(globalMsg);
ws.send(globalMsg);
}
}
}
}
@@ -2352,10 +2414,11 @@ class ChatServer {
@@ -2352,10 +2414,11 @@ class ChatServer {
broadcastGlobal(data) {
broadcastGlobal(data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// global activity via their dedicated cross-feed socket, not their main one.
// global activity via their dedicated cross-feed socket, not their main one.
- if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2434,7 +2497,9 @@ class ChatServer {
@@ -2434,7 +2497,9 @@ class ChatServer {
source_managed_id: info.managedId,
source_managed_id: info.managedId,
source_is_live: 1, // this path only fires for a live send
source_is_live: 1, // this path only fires for a live send
});
});
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
+ if (this._blockedFor(client, blockers)) continue;
// Only PURE global clients (no stream AND no channel) — otherwise an
// Only PURE global clients (no stream AND no channel) — otherwise an
// offline-channel viewer's main socket (streamId null, channelUserId set)
// offline-channel viewer's main socket (streamId null, channelUserId set)
// would render this via addChatMessage AND get it again as a cross-feed on
// would render this via addChatMessage AND get it again as a cross-feed on
@@ -2469,9 +2534,10 @@ class ChatServer {
@@ -2469,9 +2534,10 @@ class ChatServer {
source_channel: stream.username || null,
source_channel: stream.username || null,
source_is_live: 1,
source_is_live: 1,
});
});
+ const blockers = this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
ws.send(payload);
ws.send(payload);
}
}
}
}
@@ -2482,7 +2548,11 @@ class ChatServer {
@@ -2482,7 +2548,11 @@ class ChatServer {
if (!Array.isArray(ids) || ids.length === 0) return;
if (!Array.isArray(ids) || ids.length === 0) return;
const payload = { type: 'delete-messages', ids };
const payload = { type: 'delete-messages', ids };
if (streamId) {
if (streamId) {
- this.broadcastToStream(streamId, payload);
+ // The stream's room, the rest of its channel room (other slots, the offline room, a
+ // channel popout) and the global feed: everywhere the lines were shown.
+ let ownerId = null;
+ try { ownerId = ctx.getStreamById(streamId)?.user_id || null; } catch { ownerId = null; }
+ this.broadcastToChannelRoom(ownerId, streamId, payload);
this.forwardToGlobal(streamId, payload);
this.forwardToGlobal(streamId, payload);
return;
return;
}
}
@@ -2672,8 +2742,9 @@ class ChatServer {
@@ -2672,8 +2742,9 @@ class ChatServer {
*/
*/
broadcastAll(data) {
broadcastAll(data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
- for (const [ws] of this.clients) {
- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ const blockers = this._blockersOf(data);
+ for (const [ws, client] of this.clients) {
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2719,6 +2790,98 @@ class ChatServer {
@@ -2719,6 +2790,98 @@ class ChatServer {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
+ /**
+ * A room's slow mode in ms: its channel's saved slow_mode_seconds (channel moderation settings,
+ * the dashboard's value and /slow's). Read from the policy cache, so a restart keeps it.
+ */
+ slowModeMs(streamId) {
+ if (!streamId) return 0;
+ return Math.max(0, parseInt(this._getChannelChatSettings(streamId).slow_mode_seconds, 10) || 0) * 1000;
+ }
+
+ /** streamId → slow mode ms for the streams with sockets here (Live's presence read). */
+ get slowModeByStream() {
+ const out = new Map();
+ for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, this.slowModeMs(c.streamId));
+ return out;
+ }
+
+ /** The channel a client's room belongs to: { channel, ownerId } or null (global chat). */
+ async _channelOfRoom(client) {
+ const modStreamId = client.streamId || this._moderationStreamFor(client);
+ const stream = modStreamId ? ctx.getStreamById(modStreamId) : null;
+ const ownerId = (stream && stream.user_id) || client.channelUserId || null;
+ let channel = stream && stream.channel_id ? ctx.getChannelById(stream.channel_id) : null;
+ if (!channel && ownerId) channel = await ctx.ensureChannelForUser(ownerId);
+ return channel ? { channel, ownerId: channel.user_id || ownerId } : null;
+ }
+
+ _modesOf(settings) {
+ return { slow: Math.max(0, parseInt(settings && settings.slow_mode_seconds, 10) || 0), sub: settings && Number(settings.sub_only) ? 1 : 0 };
+ }
+
+ /**
+ * /slow and /subonly. The channel's saved setting is the truth: it is written where the table's
+ * authority says (Live's effect, or here once Chat writes channel_moderation_settings), then the
+ * channel's whole room hears it (every live slot, the offline room, popouts). Nothing changes when
+ * the write fails. → true when saved.
+ */
+ async _setChannelModes(ws, client, fields, label) {
+ let room = null;
+ try { room = await this._channelOfRoom(client); } catch { room = null; }
+ if (!room) { this.sendTo(ws, { type: 'system', message: `The ${label} is set per channel: use it in a channel's chat.` }); return false; }
+ const id = Number(room.channel.id);
+ // While the write runs, policy reads of this channel are not announced (one in flight may
+ // still carry the old value); the command announces the result itself.
+ this._modeWrites.set(id, (this._modeWrites.get(id) || 0) + 1);
+ try {
+ await ctx.effects.updateChannelModerationSettings(id, fields, client.user ? client.user.id : null);
+ } catch (err) {
+ this.sendTo(ws, { type: 'system', message: err && err.status === 403 ? 'You do not have permission.' : `Could not change the ${label} right now. Try again.` });
+ return false;
+ } finally {
+ const n = (this._modeWrites.get(id) || 1) - 1;
+ if (n > 0) this._modeWrites.set(id, n); else this._modeWrites.delete(id);
+ }
+ const next = this._modesOf(ctx.getChannelModerationSettings(id));
+ if (fields.slow_mode_seconds !== undefined) next.slow = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
+ if (fields.sub_only !== undefined) next.sub = fields.sub_only ? 1 : 0;
+ this._announcedModes.set(id, next);
+ // A command is always answered in the room, even when the value did not change.
+ this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);
+ return true;
+ }
+
+ /** Tell a channel's room what changed: the slowmode / subonly frame and a system line for each. */
+ _announceModes(ownerUserId, streamId, prev, next) {
+ if (!ownerUserId && !streamId) return;
+ const say = (frame) => this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);
+ if (prev.slow !== next.slow) {
+ say({ type: 'slowmode', seconds: next.slow });
+ say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });
+ }
+ if (prev.sub !== next.sub) {
+ say({ type: 'subonly', enabled: !!next.sub });
+ say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });
+ }
+ }
+
+ /**
+ * A channel's settings were read anew (live-context.onChannelSettings): when its slow mode or
+ * sub-only mode differs from what its room was last told (the dashboard changed it), tell the
+ * room. The first read after a start only records the values.
+ */
+ _channelSettingsSeen(channelId, settings) {
+ const id = Number(channelId);
+ if (!id || this._modeWrites.has(id)) return;
+ const next = this._modesOf(settings);
+ const prev = this._announcedModes.get(id);
+ this._announcedModes.set(id, next);
+ if (!prev || (prev.slow === next.slow && prev.sub === next.sub)) return;
+ const channel = ctx.getChannelById(id);
+ if (channel && channel.user_id) this._announceModes(channel.user_id, null, prev, next);
+ }
+
/**
/**
* Get channel moderation settings for a stream.
* Get channel moderation settings for a stream.
* Caches the channel lookup to avoid repeated DB queries.
* Caches the channel lookup to avoid repeated DB queries.
@@ -9,6 +9,11 @@
@@ -9,6 +9,11 @@
* DMs (./dm.js isBlockedEither) refuse a conversation, a group invite or a direct message between two
* DMs (./dm.js isBlockedEither) refuse a conversation, a group invite or a direct message between two
* people when either blocked the other, here or in Chat's own dm_blocks. Blocks are between Network
* people when either blocked the other, here or in Chat's own dm_blocks. Blocks are between Network
* subjects; Chat's people are Live ids, mapped through ctx_users.subject_id (db.subjectFor).
* subjects; Chat's people are Live ids, mapped through ctx_users.subject_id (db.subjectFor).
+ *
+ * Public chat (WS-I task 6) is one-way: the person who blocked someone no longer gets that person's
+ * lines, live (chat-server.js skips their sockets: blockersOf) or in history reads (routes.js drops
+ * them from pages and cursor reads for that reader: blockedUserIds). Everyone else, the blocked
+ * person included, sees the room as before; moderation tools and logs show everything.
*/
*/
'use strict';
'use strict';
@@ -66,7 +71,23 @@ function eitherBlockedUsers(userIdA, userIdB) {
@@ -66,7 +71,23 @@ function eitherBlockedUsers(userIdA, userIdB) {
return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));
return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));
}
}
+/** The subjects who blocked `blocked` (a subject), active blocks only. One indexed read. */
+function blockersOf(blocked) {
+ if (!SUBJECT_RE.test(String(blocked || ''))) return [];
+ ensureSchema();
+ return db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked]).map((r) => r.blocker_subject);
+}
+
+/** The Live user ids a signed-in reader blocked (through their subjects, the ctx_users projection). */
+function blockedUserIds(user) {
+ const subject = user && (user.subject_id || db.subjectFor(user.id));
+ if (!SUBJECT_RE.test(String(subject || ''))) return new Set();
+ ensureSchema();
+ return new Set(db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject
+ WHERE b.blocker_subject = ? AND b.active = 1`, [subject]).map((r) => Number(r.id)));
+}
+
/** For tests: forget that the table was created (the table stays). */
/** For tests: forget that the table was created (the table stays). */
function _reset() { ready = false; }
function _reset() { ready = false; }
-module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, _reset };
+module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, blockersOf, blockedUserIds, _reset };
@@ -17,6 +17,20 @@ const ctx = require('../live-context');
@@ -17,6 +17,20 @@ const ctx = require('../live-context');
const { optionalAuth, requireAuth } = require('../auth/auth');
const { optionalAuth, requireAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const historyStore = require('./history-store');
const historyStore = require('./history-store');
+const networkBlocks = require('./network-blocks');
+
+/**
+ * A signed-in reader does not get the lines of people they blocked on the network (one-way; only
+ * their own view: network-blocks.js). Pages and cursor reads alike; latest_id and deleted_ids stay
+ * the room's, so the reader's cursor moves on as everyone's does. Such answers are per reader.
+ */
+function withoutBlocked(req, res, rows) {
+ if (!req.user) return rows;
+ res.set('Cache-Control', 'private, no-store');
+ const hidden = networkBlocks.blockedUserIds(req.user);
+ if (!hidden.size) return rows;
+ return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
+}
const router = express.Router();
const router = express.Router();
@@ -481,6 +495,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {
@@ -481,6 +495,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {
const out = afterId != null
const out = afterId != null
? historyStore.delta('global', { afterId, limit, channelUsername })
? historyStore.delta('global', { afterId, limit, channelUsername })
: historyStore.page('global', { limit, before, channelUsername });
: historyStore.page('global', { limit, before, channelUsername });
+ out.messages = withoutBlocked(req, res, out.messages);
await loadDecor(out.messages);
await loadDecor(out.messages);
out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
res.json(out);
res.json(out);
@@ -579,6 +594,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -579,6 +594,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
let complete = true;
let complete = true;
if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
+ rows = withoutBlocked(req, res, rows);
await loadDecor(rows);
await loadDecor(rows);
const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));
const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));
@@ -623,6 +639,7 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
@@ -623,6 +639,7 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
const out = req.query.after_id != null
const out = req.query.after_id != null
? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })
? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })
: historyStore.page(room, { limit: req.query.limit, before: req.query.before });
: historyStore.page(room, { limit: req.query.limit, before: req.query.before });
+ out.messages = withoutBlocked(req, res, out.messages);
await loadDecor(out.messages);
await loadDecor(out.messages);
out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
let liveSlots = [], channel = null;
let liveSlots = [], channel = null;
@@ -718,14 +735,25 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
@@ -718,14 +735,25 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
- // Broadcast delete event to live chat
+ // Every surface that showed those lines: the stream's sockets get the range (Live's chat.js
+ // drops it by time, with its "purged" notice) and, by id, the rest of the channel room (other
+ // slots, the offline room, a channel popout) and the global feed. Cursor reads report them
+ // in deleted_ids (history-store.js).
try {
try {
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };
const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };
+ const ids = (result && result.ids) || [];
+ const gone = { type: 'delete-messages', ids };
if (effectiveStreamId) {
if (effectiveStreamId) {
chatServer.broadcastToStream(effectiveStreamId, payload);
chatServer.broadcastToStream(effectiveStreamId, payload);
+ if (ids.length) {
+ const ownerId = ctx.getStreamById(effectiveStreamId)?.user_id || null;
+ chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);
+ chatServer.forwardToGlobal(effectiveStreamId, gone);
+ }
} else {
} else {
chatServer.broadcastGlobal(payload);
chatServer.broadcastGlobal(payload);
+ if (ids.length) chatServer.broadcastGlobal(gone);
}
}
} catch { /* chat server may not be initialized */ }
} catch { /* chat server may not be initialized */ }
@@ -117,6 +117,7 @@ const ADDED_COLUMNS = [
@@ -117,6 +117,7 @@ const ADDED_COLUMNS = [
['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
['emotes', 'media_asset_id', 'INTEGER'],
['emotes', 'media_asset_id', 'INTEGER'],
['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
+ ['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too
];
];
function initDb({ captureMirror = false } = {}) {
function initDb({ captureMirror = false } = {}) {
@@ -443,7 +444,8 @@ function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
@@ -443,7 +444,8 @@ function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
[deletedBy, ...params]
[deletedBy, ...params]
);
);
_announceDeleted(ids);
_announceDeleted(ids);
- return res;
+ // The ids too, so the purge reaches every surface that showed them (not only the stream's sockets).
+ return Object.assign(res, { ids });
});
});
}
}
@@ -1024,10 +1026,11 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -1024,10 +1026,11 @@ function upsertChannelModerationSettings(channelId, fields) {
]
]
);
);
}
}
- // tts_max_length, as Live: after the UPDATE or the fresh INSERT.
+ // tts_max_length and sub_only, as Live: after the UPDATE or the fresh INSERT.
if (fields.tts_max_length !== undefined) {
if (fields.tts_max_length !== undefined) {
run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);
run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);
}
}
+ if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
return _staged(row, 'channel_moderation_settings', [row]);
return _staged(row, 'channel_moderation_settings', [row]);
});
});
@@ -241,7 +241,8 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
@@ -241,7 +241,8 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
sound_max_pitch_cents INTEGER DEFAULT 1200,
sound_max_pitch_cents INTEGER DEFAULT 1200,
emote_size_min INTEGER DEFAULT 50,
emote_size_min INTEGER DEFAULT 50,
emote_size_max INTEGER DEFAULT 200,
emote_size_max INTEGER DEFAULT 200,
- sounds_mods_only INTEGER DEFAULT 0
+ sounds_mods_only INTEGER DEFAULT 0,
+ sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators
);
);
CREATE TABLE IF NOT EXISTS emotes (
CREATE TABLE IF NOT EXISTS emotes (
@@ -34,9 +34,13 @@
@@ -34,9 +34,13 @@
* channels getChannelById, getChannelByUserId (sync) · ensureChannelForUser(uid),
* channels getChannelById, getChannelByUserId (sync) · ensureChannelForUser(uid),
* createChannel(uid) (effect)
* createChannel(uid) (effect)
* policy getChannelModerationSettings(channelId), isChannelModerator(uid, channelId),
* policy getChannelModerationSettings(channelId), isChannelModerator(uid, channelId),
- * channelLanguage(channelUserId), getChannelAlertSoundsByUser(uid) · invalidateChannel
+ * channelLanguage(channelUserId), getChannelAlertSoundsByUser(uid) · invalidateChannel,
+ * reloadPolicy(channelId) (a fresh read after a write), onChannelSettings(fn)
+ * (fn(channelId, settings) whenever a channel's settings are read anew)
* bans isUserBanned(uid, streamId), getIpBan(ip, streamId), isIpBanned(ip, streamId)
* bans isUserBanned(uid, streamId), getIpBan(ip, streamId), isIpBanned(ip, streamId)
* follows isFollowing(followerId, streamerId)
* follows isFollowing(followerId, streamerId)
+ * subscriptions isSubscriber(uid, streamerId), subscriberState(…) → true | false | null (unknown),
+ * ensureSubscriber(…) (sub-only chat; Live's active channel subscriptions)
* approvals isIpApproved(channelId, ip), approveIp(channelId, ip, by, source) (effect)
* approvals isIpApproved(channelId, ip), approveIp(channelId, ip, by, source) (effect)
* decor getCosmeticProfile(uid), getTagProfile(uid), ensureDecor(ids)
* decor getCosmeticProfile(uid), getTagProfile(uid), ensureDecor(ids)
* settings getSetting(key), ensureSettings(), setSettings(map, actor) (effect)
* settings getSetting(key), ensureSettings(), setSettings(map, actor) (effect)
@@ -65,7 +69,10 @@ const TTL = {
@@ -65,7 +69,10 @@ const TTL = {
settings: 30_000,
settings: 30_000,
bans: 10_000,
bans: 10_000,
streamRow: 30_000,
streamRow: 30_000,
+ subs: 60_000,
};
};
+// A subscription answer older than this is not used at all (sub-only fails closed while Live is away).
+const SUB_MAX_STALE_MS = 10 * 60_000;
const WARM_TIMEOUT_MS = 2500;
const WARM_TIMEOUT_MS = 2500;
const PAGE = 1000;
const PAGE = 1000;
@@ -113,6 +120,7 @@ function defaultModerationSettings(channelId) {
@@ -113,6 +120,7 @@ function defaultModerationSettings(channelId) {
sound_max_speed: 3.0,
sound_max_speed: 3.0,
sound_min_pitch_cents: -1200,
sound_min_pitch_cents: -1200,
sound_max_pitch_cents: 1200,
sound_max_pitch_cents: 1200,
+ sub_only: 0,
};
};
}
}
@@ -328,9 +336,22 @@ async function createChannel(userId) {
@@ -328,9 +336,22 @@ async function createChannel(userId) {
// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
+// Everyone who wants to know when a channel's settings were read anew (the chat server announces a
+// slow mode or sub-only mode the dashboard changed). Called with every fresh read; the listener
+// compares with what it last saw.
+const _settingsListeners = new Set();
+function onChannelSettings(fn) { _settingsListeners.add(fn); return () => _settingsListeners.delete(fn); }
+function _emitSettings(channelId, settings) {
+ for (const fn of _settingsListeners) {
+ try { fn(Number(channelId), settings || defaultModerationSettings(channelId)); } catch (err) { console.warn('[LiveContext] channel settings listener:', err.message); }
+ }
+}
+
const _policy = new Swr(TTL.policy, async (channelId) => {
const _policy = new Swr(TTL.policy, async (channelId) => {
const data = await read(`/channels/${channelId}/policy`);
const data = await read(`/channels/${channelId}/policy`);
if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
+ // While Chat writes the settings itself, Live's copy is a mirror that may lag: not announced.
+ if (!_chatWrites('channel_moderation_settings')) _emitSettings(channelId, data.settings || defaultModerationSettings(channelId));
return {
return {
settings: data.settings || null,
settings: data.settings || null,
moderators: new Set((data.moderator_ids || []).map(Number)),
moderators: new Set((data.moderator_ids || []).map(Number)),
@@ -369,7 +390,21 @@ function getChannelAlertSoundsByUser(userId) {
@@ -369,7 +390,21 @@ function getChannelAlertSoundsByUser(userId) {
return pick;
return pick;
}
}
function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }
function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }
-function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number(channelId)); }
+function invalidateChannel(channelId) {
+ if (!channelId) return;
+ _policy.invalidate(Number(channelId));
+ if (_chatWrites('channel_moderation_settings')) _emitSettings(channelId, getChannelModerationSettings(channelId));
+}
+/**
+ * A fresh policy read that starts after the call (Chat just had Live write a setting): a load already
+ * in flight may have left before the write, so it is waited for and followed by another.
+ */
+function reloadPolicy(channelId) {
+ const k = Number(channelId);
+ if (!k) return Promise.resolve();
+ const inflight = _policy.inflight.get(k);
+ return (inflight ? inflight.catch(() => {}) : Promise.resolve()).then(() => _policy.refresh(k));
+}
// ── Bans (Live's `bans`: user, IP and CIDR rows) ────────────────────────────────────────────
// ── Bans (Live's `bans`: user, IP and CIDR rows) ────────────────────────────────────────────
@@ -470,6 +505,39 @@ function isFollowing(followerId, streamerId) {
@@ -470,6 +505,39 @@ function isFollowing(followerId, streamerId) {
return !!(s && s.has(Number(streamerId)));
return !!(s && s.has(Number(streamerId)));
}
}
+// Sub-only chat: does this person hold an ACTIVE subscription to the streamer's channel (Live's
+// subscriptions, GET /subscriber)? Network VIP does not count. Cached per (user, streamer) for a
+// minute; an answer older than SUB_MAX_STALE_MS, or none yet, is unknown (null), which sub-only
+// treats as "no": it fails closed when Live cannot be asked.
+const subKey = (userId, streamerId) => `${Number(userId)}|${Number(streamerId)}`;
+const _subs = new Swr(TTL.subs, async (key) => {
+ const [userId, streamerId] = key.split('|');
+ return !!(await read(`/subscriber?user_id=${userId}&streamer_id=${streamerId}`)).subscriber;
+});
+function subscriberState(userId, streamerId) {
+ if (!userId || !streamerId) return false;
+ const key = subKey(userId, streamerId);
+ const hit = _subs.map.get(key);
+ const age = hit ? Date.now() - hit.at : Infinity;
+ if (age > TTL.subs) _subs.refresh(key).catch(() => {});
+ return age > SUB_MAX_STALE_MS ? null : hit.value;
+}
+function isSubscriber(userId, streamerId) { return subscriberState(userId, streamerId) === true; }
+/** Ask Live when the cached answer is missing or older than `maxAgeMs`; waits at most `timeoutMs`. → true | false | null */
+async function ensureSubscriber(userId, streamerId, { maxAgeMs = TTL.subs, timeoutMs = 2000 } = {}) {
+ if (!userId || !streamerId) return false;
+ const key = subKey(userId, streamerId);
+ const hit = _subs.map.get(key);
+ if (hit && Date.now() - hit.at <= maxAgeMs) return hit.value;
+ let timer;
+ await Promise.race([
+ _subs.refresh(key).catch((err) => console.warn(`[LiveContext] subscriber ${key}: ${err.message}`)),
+ new Promise((r) => { timer = setTimeout(r, timeoutMs); if (timer.unref) timer.unref(); }),
+ ]);
+ clearTimeout(timer);
+ return subscriberState(userId, streamerId);
+}
+
const _approvals = new Swr(TTL.approval, async (key) => {
const _approvals = new Swr(TTL.approval, async (key) => {
const [channelId, ip] = key.split('|');
const [channelId, ip] = key.split('|');
return !!(await read(`/channels/${channelId}/approved-ip?ip=${encodeURIComponent(ip)}`)).approved;
return !!(await read(`/channels/${channelId}/approved-ip?ip=${encodeURIComponent(ip)}`)).approved;
@@ -629,6 +697,7 @@ function invalidateUser(userId) {
@@ -629,6 +697,7 @@ function invalidateUser(userId) {
for (const [k, v] of _auth) if (v.userId === id) _auth.delete(k);
for (const [k, v] of _auth) if (v.userId === id) _auth.delete(k);
_decor.invalidate(id);
_decor.invalidate(id);
_follows.invalidate(id);
_follows.invalidate(id);
+ for (const k of [..._subs.map.keys()]) if (k.startsWith(`${id}|`)) _subs.delete(k);
}
}
// ── Warm-up (connect / join) ─────────────────────────────────────────────────────────────────
// ── Warm-up (connect / join) ─────────────────────────────────────────────────────────────────
@@ -655,6 +724,10 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
@@ -655,6 +724,10 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
const policy = channel ? _policyFor(channel.id) : null;
const policy = channel ? _policyFor(channel.id) : null;
if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
+ // Sub-only room: the viewer's subscription answer, so their first line does not wait for it.
+ if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {
+ await ensureSubscriber(user.id, ownerId).catch(() => {});
+ }
})();
})();
await Promise.race([job.catch(() => {}), new Promise((r) => setTimeout(r, WARM_TIMEOUT_MS).unref?.())]);
await Promise.race([job.catch(() => {}), new Promise((r) => setTimeout(r, WARM_TIMEOUT_MS).unref?.())]);
}
}
@@ -806,11 +879,19 @@ const effects = {
@@ -806,11 +879,19 @@ const effects = {
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
// /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat
// /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat
// does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).
// does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).
+ // The saved value is what chat enforces: at 'live' the policy is read again (awaited) before the
+ // caller answers, so the next line already follows it. Chat sets slow_mode_seconds and sub_only.
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
- if (!_chatWrites('channel_moderation_settings')) return effect('channel-settings', { channel_id: channelId, fields, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; });
+ const f = {};
+ if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
+ if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;
+ if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));
+ if (!_chatWrites('channel_moderation_settings')) {
+ return effect('channel-settings', { channel_id: channelId, fields: f, actor_user_id: actorUserId })
+ .then((r) => reloadPolicy(channelId).catch(() => {}).then(() => r));
+ }
return Promise.resolve().then(() => {
return Promise.resolve().then(() => {
- if (!channelId || !fields || fields.slow_mode_seconds === undefined) throw new LiveError(400, 'no chat-settable fields');
- db.upsertChannelModerationSettings(Number(channelId), { slow_mode_seconds: Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0) });
+ db.upsertChannelModerationSettings(Number(channelId), f);
return { ok: true };
return { ok: true };
});
});
},
},
@@ -860,7 +941,9 @@ module.exports = {
@@ -860,7 +941,9 @@ module.exports = {
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
// policy
// policy
getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
- defaultModerationSettings,
+ reloadPolicy, onChannelSettings, defaultModerationSettings,
+ // subscriptions (sub-only chat)
+ isSubscriber, subscriberState, ensureSubscriber,
// bans, follows, approvals, decor
// bans, follows, approvals, decor
isUserBanned, getIpBan, isIpBanned, invalidateBans, refreshBans,
isUserBanned, getIpBan, isIpBanned, invalidateBans, refreshBans,
isFollowing, isIpApproved, approveIp, invalidateApprovals,
isFollowing, isIpApproved, approveIp, invalidateApprovals,
@@ -873,7 +956,7 @@ module.exports = {
@@ -873,7 +956,7 @@ module.exports = {
// tests
// tests
_setFetch(fn) { _fetch = fn; },
_setFetch(fn) { _fetch = fn; },
_reset() {
_reset() {
- _policy.clear(); _follows.clear(); _approvals.clear(); _decor.clear(); _auth.clear(); _anon.clear(); _anonSeen.clear(); _streamFetch.clear();
+ _policy.clear(); _follows.clear(); _approvals.clear(); _decor.clear(); _auth.clear(); _anon.clear(); _anonSeen.clear(); _streamFetch.clear(); _subs.clear();
_bans = { rows: [], version: null, at: 0, cidr: [] }; _settings = { values: {}, at: 0 }; _lastRun.clear();
_bans = { rows: [], version: null, at: 0, cidr: [] }; _settings = { values: {}, at: 0 }; _lastRun.clear();
},
},
};
};
@@ -130,7 +130,9 @@ function createWebRoutes({ config }) {
@@ -130,7 +130,9 @@ function createWebRoutes({ config }) {
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const { messages, latest_id } = historyStore.page('global', { limit: 60 });
const { messages, latest_id } = historyStore.page('global', { limit: 60 });
- const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat');
+ // Not the lines of people this reader blocked on the network (chat/network-blocks.js).
+ const hidden = actor.kind === 'user' ? require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
+ const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
const composer = actor.kind === 'user'
const composer = actor.kind === 'user'
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
<label for="oc-input" class="oc-sr">Message to everyone</label>
<label for="oc-input" class="oc-sr">Message to everyone</label>
@@ -120,6 +120,8 @@ async function boot({ env = {} } = {}) {
@@ -120,6 +120,8 @@ async function boot({ env = {} } = {}) {
channels: new Map(), // id → { id, user_id, title }
channels: new Map(), // id → { id, user_id, title }
policies: new Map(), // channelId → { settings, moderator_ids }
policies: new Map(), // channelId → { settings, moderator_ids }
follows: new Map(), // userId → [streamerIds]
follows: new Map(), // userId → [streamerIds]
+ subscribers: new Set(), // `${userId}|${streamerId}`: an active channel subscription
+ subscriberDown: false, // GET /subscriber answers 503 (Live cannot say)
approved: new Set(), // `${channelId}|${ip}`
approved: new Set(), // `${channelId}|${ip}`
bans: [], // rows
bans: [], // rows
settings: { tts_enabled: true, tts_per_user_voices: true, gif_tenor_api_key: '' },
settings: { tts_enabled: true, tts_per_user_voices: true, gif_tenor_api_key: '' },
@@ -174,6 +176,12 @@ async function boot({ env = {} } = {}) {
@@ -174,6 +176,12 @@ async function boot({ env = {} } = {}) {
return send(200, { users: out });
return send(200, { users: out });
}
}
if ((m = /^\/users\/(\d+)\/follows$/.exec(q))) return send(200, { streamer_ids: live.follows.get(Number(m[1])) || [] });
if ((m = /^\/users\/(\d+)\/follows$/.exec(q))) return send(200, { streamer_ids: live.follows.get(Number(m[1])) || [] });
+ if (q === '/subscriber') {
+ if (live.subscriberDown) return send(503, { error: 'subscription lookup failed' });
+ const u = Number(url.searchParams.get('user_id')), st = Number(url.searchParams.get('streamer_id'));
+ if (!u || !st) return send(400, { error: 'user_id (or subject) and streamer_id required' });
+ return send(200, { subscriber: live.subscribers.has(`${u}|${st}`), user_id: u, streamer_id: st });
+ }
if (q === '/users/profile') {
if (q === '/users/profile') {
const n = url.searchParams.get('username');
const n = url.searchParams.get('username');
const u = [...live.users.values()].find((x) => x.username.toLowerCase() === String(n).toLowerCase());
const u = [...live.users.values()].find((x) => x.username.toLowerCase() === String(n).toLowerCase());
@@ -3,15 +3,18 @@
@@ -3,15 +3,18 @@
* Chat parity (roadmap WS-I task 6). scripts/parity.js's scenarios (join, send, DM, /tts,
* Chat parity (roadmap WS-I task 6). scripts/parity.js's scenarios (join, send, DM, /tts,
* soundboard, ban, timeout, purge, slow mode, sub-only mode, the popout, reconnect convergence for
* soundboard, ban, timeout, purge, slow mode, sub-only mode, the popout, reconnect convergence for
* deleted and blocked messages) run here against Chat booted with stubs, with two or three people
* deleted and blocked messages) run here against Chat booted with stubs, with two or three people
- * connected, each from their own address: the same code the CLI runs against a running Chat.
+ * connected, each from their own address: the same code the CLI runs against a running Chat. Alice
+ * (A) holds an active subscription to the channel (the stub Live's GET /subscriber); the driver sets
+ * network blocks itself (network.block.changed, as the Events consumer applies it).
*
*
* On top, what only a test can reach: Live's moderator paths through the bridge (the context-menu
* On top, what only a test can reach: Live's moderator paths through the bridge (the context-menu
* ban that disconnects, the single-message delete) and the global feed's cursor read (Live's
* ban that disconnects, the single-message delete) and the global feed's cursor read (Live's
- * floating widget, openvibe.chat), the DM participant rule on delivery, and the CLI itself: its dry
- * run touches nothing, and --apply refuses to run for an account that is not a named test account.
- *
- * A scenario the product does not support ends as a gap with its reason (sub-only mode, blocks in
- * public chat); nothing here asserts that it works. The matrix is printed at the end.
+ * floating widget, openvibe.chat), sub-only failing closed when Live cannot be asked, slow mode as
+ * the saved setting (a restart of every cache, the dashboard's value), /clear's answer, blocks
+ * leaving moderation logs and DMs as they were, the DM participant rule on delivery, and the CLI
+ * itself: its dry run touches nothing, and --apply refuses to run for an account that is not a
+ * named test account. Every scenario must pass here: none is a gap or a skip. The matrix is printed
+ * at the end.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
@@ -22,6 +25,7 @@ let h, p, streamer, mod, alice, bob, dave, channelId, streamId;
@@ -22,6 +25,7 @@ let h, p, streamer, mod, alice, bob, dave, channelId, streamId;
const IPS = { a: '198.51.100.101', b: '198.51.100.102', mod: '198.51.100.103', streamer: '198.51.100.104', anon: '198.51.100.105', dave: '198.51.100.106' };
const IPS = { a: '198.51.100.101', b: '198.51.100.102', mod: '198.51.100.103', streamer: '198.51.100.104', anon: '198.51.100.105', dave: '198.51.100.106' };
const results = [];
const results = [];
let bridgeSeq = 0;
let bridgeSeq = 0;
+let blockRev = 0;
const bridge = (ops) => h.http('POST', '/internal/live/calls', {
const bridge = (ops) => h.http('POST', '/internal/live/calls', {
token: h.serviceToken(['chat.live_bridge.write', 'chat.message.send']),
token: h.serviceToken(['chat.live_bridge.write', 'chat.message.send']),
body: { boot: 'parity-live', ops: ops.map((o) => ({ seq: ++bridgeSeq, ...o })) },
body: { boot: 'parity-live', ops: ops.map((o) => ({ seq: ++bridgeSeq, ...o })) },
@@ -43,6 +47,11 @@ const driver = {
@@ -43,6 +47,11 @@ const driver = {
address: (role) => IPS[role],
address: (role) => IPS[role],
connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),
connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),
http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),
http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),
+ // A network block, as the Events consumer applies network.block.changed.
+ block: (blocker, blocked, active) => {
+ const subject = { a: alice, b: bob, mod, streamer }[blocker].subject_id, target = { a: alice, b: bob, mod, streamer }[blocked].subject_id;
+ require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });
+ },
};
};
t('boot: a channel with a moderator, a live stream, a channel sound, TTS that synthesizes', async () => {
t('boot: a channel with a moderator, a live stream, a channel sound, TTS that synthesizes', async () => {
@@ -54,6 +63,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
@@ -54,6 +63,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
dave = h.addUser('dave');
dave = h.addUser('dave');
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
+ h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does not
await h.ctx.sync();
await h.ctx.sync();
// TTS without an engine: every utterance is 0.2 s of silence.
// TTS without an engine: every utterance is 0.2 s of silence.
const tts = require('../server/chat/tts-engine');
const tts = require('../server/chat/tts-engine');
@@ -70,6 +80,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
@@ -70,6 +80,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
driver, streamId, sound: 'honk',
driver, streamId, sound: 'honk',
tokens: { a: alice.token, b: bob.token, mod: mod.token, streamer: streamer.token },
tokens: { a: alice.token, b: bob.token, mod: mod.token, streamer: streamer.token },
testAccounts: ['alice', 'bob', 'moddy', 'streamer'],
testAccounts: ['alice', 'bob', 'moddy', 'streamer'],
+ subscriber: 'a',
});
});
await p.resolve();
await p.resolve();
assert.deepStrictEqual(Object.fromEntries(Object.entries(p.users).map(([r, u]) => [r, u.id])), { a: alice.id, b: bob.id, mod: mod.id, streamer: streamer.id });
assert.deepStrictEqual(Object.fromEntries(Object.entries(p.users).map(([r, u]) => [r, u.id])), { a: alice.id, b: bob.id, mod: mod.id, streamer: streamer.id });
@@ -155,6 +166,97 @@ t('reconnect in global chat (Live’s floating widget, openvibe.chat): Live’s
@@ -155,6 +166,97 @@ t('reconnect in global chat (Live’s floating widget, openvibe.chat): Live’s
await p.closeAll();
await p.closeAll();
});
});
+t('sub-only fails closed: while Live cannot say whether someone subscribes they are told sub-only is on; the streamer, moderators and a known subscriber talk', async () => {
+ const q = new parity.Parity({ driver, tokens: { ...p.tokens, dave: dave.token }, streamId });
+ q.ownerId = streamer.id;
+ const modWs = await q.join('mod'); const a = await q.join('a');
+ await q.say(modWs, '/subonly');
+ await a.next((m) => m.type === 'subonly' && m.enabled === true);
+ h.live.subscriberDown = true;
+ try {
+ // Nobody has asked Live about dave; Alice's "yes" is cached from the subonly scenario.
+ const daveWs = await q.join('dave');
+ await q.sleep(parity.PACE_MS); // dave's last line (the ban test) may be under a second old
+ const t1 = q.text('dave while Live cannot say');
+ await q.say(daveWs, t1);
+ assert.match((await daveWs.next((m) => m.type === 'system' && /^This chat is in sub-only mode/.test(m.message))).message, /only subscribers/);
+ assert.ok(await a.none((m) => m.type === 'chat' && m.message === t1), 'fails closed: the line reaches nobody');
+ const t2 = q.text('alice, a known subscriber'); await q.say(a, t2); await q.saw(modWs, t2);
+ const t3 = q.text('the moderator'); await q.say(modWs, t3); await q.saw(a, t3);
+ const own = await q.join('streamer');
+ const t4 = q.text('the streamer'); await q.say(own, t4); await q.saw(a, t4);
+ } finally {
+ h.live.subscriberDown = false;
+ await q.say(modWs, '/subonly off');
+ await a.next((m) => m.type === 'subonly' && m.enabled === false);
+ await q.closeAll();
+ }
+});
+
+t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {
+ const pol = h.live.policies.get(channelId);
+ const a = await p.join('a'); const b = await p.join('b');
+ // The dashboard saves 3 s in Live, which tells Chat (the bridge's invalidate: Live chat-remote.js OBSERVED_DB).
+ pol.settings = { ...(pol.settings || {}), slow_mode_seconds: 3 };
+ await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);
+ assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');
+ await p.system(b, 'Slow mode enabled: 3s between messages');
+ const slowed = async (ws, label) => {
+ await p.sleep(3200);
+ const one = p.text(`${label} one`), two = p.text(`${label} two`);
+ await p.say(ws, one); await p.saw(b, one);
+ await p.say(ws, two);
+ await p.system(ws, 'Slow down! You are sending messages too fast.');
+ assert.ok(await b.none((m) => m.type === 'chat' && m.message === two), `${label}: a slowed line reaches nobody`);
+ };
+ await slowed(a, 'dashboard slow');
+ // A restart: every cache is gone and nothing was announced yet; the value is read back from Live.
+ h.ctx._reset();
+ h.chatServer._announcedModes.clear();
+ const again = await p.join('a');
+ assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');
+ await slowed(again, 'after a restart');
+ // The dashboard turns it off.
+ pol.settings.slow_mode_seconds = 0;
+ await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);
+ assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);
+ await p.system(b, 'Slow mode disabled.');
+ await p.closeAll();
+});
+
+t('/clear clears screens only and says so: the lines stay in history', async () => {
+ const a = await p.join('a'); const modWs = await p.join('mod');
+ const line = p.text('still in history after /clear');
+ await p.say(a, line);
+ const m = await p.saw(modWs, line);
+ await p.say(modWs, '/clear');
+ await a.next((f) => f.type === 'clear');
+ await p.system(modWs, 'Chat cleared on screen; messages stay in history — use purge to remove them.');
+ assert.ok((await h.http('GET', `/api/chat/${streamId}/history?limit=50`)).body.messages.some((x) => x.id === m.id), 'still in history');
+ await p.closeAll();
+});
+
+t('blocks in public chat leave the rest alone: the streamer who blocked B does not get B’s lines, their moderation log does; DMs are refused both ways as before', async () => {
+ driver.block('streamer', 'b', true);
+ try {
+ const own = await p.join('streamer'); const b = await p.join('b'); const a = await p.join('a');
+ const t1 = p.text('B, blocked by the streamer');
+ await p.say(b, t1);
+ await p.saw(a, t1);
+ assert.ok(await own.none((m) => m.type === 'chat' && m.message === t1), 'the blocker does not get it');
+ const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&search=${encodeURIComponent(t1)}`, { token: streamer.token });
+ assert.strictEqual(logs.status, 200, logs.text);
+ assert.ok(logs.body.rows.some((r) => r.message === t1), 'the moderation log shows everything');
+ for (const [from, to] of [[bob, streamer], [streamer, bob]]) {
+ const conv = await h.http('POST', '/api/dm/conversations', { token: from.token, body: { user_ids: [to.id] } });
+ assert.strictEqual(conv.status, 403, `a DM ${from.username} → ${to.username} (${conv.text})`);
+ }
+ } finally {
+ driver.block('streamer', 'b', false);
+ await p.closeAll();
+ }
+});
+
t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses accounts that are not named test accounts', async () => {
t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses accounts that are not named test accounts', async () => {
const env = {
const env = {
OV_PARITY_BASE: h.base, OV_PARITY_STREAM: String(streamId), OV_PARITY_SOUND: 'honk',
OV_PARITY_BASE: h.base, OV_PARITY_STREAM: String(streamId), OV_PARITY_SOUND: 'honk',
@@ -189,7 +291,8 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses ac
@@ -189,7 +291,8 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses ac
t('the parity matrix', async () => {
t('the parity matrix', async () => {
const width = Math.max(...results.map((r) => r.key.length));
const width = Math.max(...results.map((r) => r.key.length));
for (const r of results) console.log(` ${r.key.padEnd(width)} ${r.status.toUpperCase().padEnd(4)} ${r.note}`);
for (const r of results) console.log(` ${r.key.padEnd(width)} ${r.status.toUpperCase().padEnd(4)} ${r.note}`);
- assert.deepStrictEqual(results.filter((r) => r.status === 'gap').map((r) => r.key), ['subonly', 'blocked'], 'the known gaps (see scripts/parity.js)');
+ assert.deepStrictEqual(results.map((r) => r.key), parity.SCENARIOS.map((s) => s.key), 'every scenario ran');
+ assert.deepStrictEqual(results.filter((r) => r.status !== 'pass').map((r) => `${r.key}: ${r.status}`), [], 'no gap, skip or failure');
});
});
t.run(async () => { if (p) await p.closeAll(); if (h) await h.close(); });
t.run(async () => { if (p) await p.closeAll(); if (h) await h.close(); });
@@ -125,7 +125,7 @@ t('joining the channel room without the stream id does not skip slow mode or IP
@@ -125,7 +125,7 @@ t('joining the channel room without the stream id does not skip slow mode or IP
sneaky.sendJson({ type: 'chat', message: 'second within slow mode' });
sneaky.sendJson({ type: 'chat', message: 'second within slow mode' });
await sneaky.next((m) => m.type === 'system' && /too fast/.test(m.message));
await sneaky.next((m) => m.type === 'system' && /too fast/.test(m.message));
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
- h.chatServer.slowModeByStream.set(streamX, 0); // (the streamer is inside their own 30s window)
+ // (/slow 30 is the channel's saved setting now; the policy set below replaces it)
h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };
h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };
h.ctx.invalidateChannel(chanX);
h.ctx.invalidateChannel(chanX);