openvibers/openvibe.live Commits
Chat parity gaps, Live's side (roadmap WS-I task 6): the subscriber read for sub-only chat, sub_only as a channel setting, the dashboard's slow mode actually saved, a dashboard delete reaching the whole channel room
Changes
@@@ -41,9 +41,17 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches### Moderation Tools- Ban, timeout, unban users--- Message deletion (individual or bulk time-range purge)--- Slow mode (configurable cooldown)--- Subscribers-only mode++- Message deletion (individual or bulk time-range purge): the lines leave every surface that showed++ them (the stream, the rest of the channel room and its popouts, the global feed) and cursor reads++ name them in `deleted_ids`. `/clear` only clears screens; the lines stay in history++- Slow mode: the channel's saved `slow_mode_seconds` (dashboard, or `/slow N` / `/slow off`), enforced++ by OpenVibe.Chat and kept across restarts; moderators are exempt++- Sub-only mode (`sub_only`: dashboard, or `/subonly` / `/subonly off` by the streamer, channel mods++ and chat staff): only people with an active channel subscription (`subscriptions`, asked through++ `GET /internal/chat-context/subscriber`), the streamer, channel mods and chat staff may chat; Network++ VIP does not count, anonymous viewers cannot chat, and when Live cannot be asked the answer is no++- Blocks: someone who blocked a person on OpenVibe.Network no longer gets that person's lines, live or++ in history reads (only their own view; moderation logs show everything)- Word filtering and opsec filtering### Chat Logs & Admin@@@ -23,10 +23,11 @@ const SLASH_COMMANDS = [{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },++ { name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },{ name: 'ban', desc: 'Ban a user (mod)', usage: '/ban <user>' },{ name: 'unban', desc: 'Unban a user (mod)', usage: '/unban <user>' },{ name: 'timeout', desc: 'Timeout a user (mod)', usage: '/timeout <user> [s]' },-- { name: 'clear', desc: 'Clear chat messages (mod)' },++ { name: 'clear', desc: 'Clear chat screens; history stays (mod)' },];/* ── State ────────────────────────────────────────────────────── */@@@ -98,7 +98,7 @@ function renderDashModerationChannels(channels) {<div class="dash-mod-section"><h5><i class="fa-solid fa-sliders"></i> Chat Settings</h5><div class="dash-mod-settings">-- <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slowmode_seconds || 0)}"></label>++ <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slow_mode_seconds || 0)}"></label><label><span>Max Message Length</span><input type="number" min="1" max="${(currentUser?.role === 'admin') ? 6000 : 4000}" id="dash-mod-maxlen-${channel.id}" class="form-input" value="${Number(settings.max_message_length || 500)}"></label><label><span>Max TTS Length</span><input type="number" min="10" max="1200" id="dash-mod-ttslen-${channel.id}" class="form-input" value="${Number(settings.tts_max_length || 200)}"></label><label><span>Account Age Gate (hours)</span><input type="number" id="dash-mod-age-${channel.id}" class="form-input" value="${Number(settings.account_age_gate_hours || 0)}"></label>@@@ -128,6 +128,7 @@ function renderDashModerationChannels(channels) {<textarea id="dash-mod-slur-msg-${channel.id}" class="form-input" rows="2" placeholder="Friendly/funny message shown when blocked">${esc(String(settings.slur_filter_nudge_message || ''))}</textarea></label><label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-followers-${channel.id}" ${Number(settings.followers_only || 0) ? 'checked' : ''}> Followers Only</label>++ <label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-subonly-${channel.id}" ${Number(settings.sub_only || 0) ? 'checked' : ''}> <span>Sub-only chat (subscribers and mods; also <code>/subonly</code>)</span></label><label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-${channel.id}" ${Number(settings.soundboard_enabled ?? 1) ? 'checked' : ''}> Allow 101soundboards</label><label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-pitch-${channel.id}" ${Number(settings.soundboard_allow_pitch ?? 1) ? 'checked' : ''}> Allow soundboard pitch changes</label><label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-speed-${channel.id}" ${Number(settings.soundboard_allow_speed ?? 1) ? 'checked' : ''}> Allow soundboard speed changes</label>@@@ -291,7 +292,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModeratawait api(`/channels/${channelId}/moderation`, {method: 'PUT',body: {-- slowmode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),++ slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),max_message_length: Number(document.getElementById(`dash-mod-maxlen-${channelId}`)?.value || 500),tts_max_length: Number(document.getElementById(`dash-mod-ttslen-${channelId}`)?.value || 200),account_age_gate_hours: Number(document.getElementById(`dash-mod-age-${channelId}`)?.value || 0),@@@ -311,6 +312,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModeratslur_filter_regexes: String(document.getElementById(`dash-mod-slur-regex-${channelId}`)?.value || ''),slur_filter_nudge_message: String(document.getElementById(`dash-mod-slur-msg-${channelId}`)?.value || ''),followers_only: !!document.getElementById(`dash-mod-followers-${channelId}`)?.checked,++ sub_only: !!document.getElementById(`dash-mod-subonly-${channelId}`)?.checked,soundboard_enabled: !!document.getElementById(`dash-mod-soundboard-${channelId}`)?.checked,soundboard_allow_pitch: !!document.getElementById(`dash-mod-soundboard-pitch-${channelId}`)?.checked,soundboard_allow_speed: !!document.getElementById(`dash-mod-soundboard-speed-${channelId}`)?.checked,@@@ -175,12 +175,15 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r// The dashboard form sends every field, so for a moderator these are ignored rather than refused.if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];const settings = await chatTables.write('upsertChannelModerationSettings', req.channel.id, {-- slow_mode_seconds: req.body.slow_mode_seconds !== undefined-- ? Math.max(0, parseInt(req.body.slow_mode_seconds) || 0) : undefined,-- slowmode_seconds: req.body.slowmode_seconds !== undefined-- ? Math.max(0, parseInt(req.body.slowmode_seconds) || 0) : undefined,++ // The column is slow_mode_seconds. The dashboard used to send (and read) slowmode_seconds,++ // which no writer knows, so its slow mode was never saved; a cached dashboard still sends it.++ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined++ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,followers_only: req.body.followers_only !== undefined? parseBoolean(req.body.followers_only, false) : undefined,++ // Sub-only chat (enforced by OpenVibe.Chat): active subscribers and the channel's moderators.++ sub_only: req.body.sub_only !== undefined++ ? parseBoolean(req.body.sub_only, false) : undefined,emote_only: req.body.emote_only,allow_anonymous: req.body.allow_anonymous !== undefined? parseBoolean(req.body.allow_anonymous, true) : undefined,@@@ -341,13 +344,14 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, redb.deleteChatMessage(messageId, req.user.id);-- // Broadcast deletion to connected clients-- if (message.stream_id) {-- chatServer.broadcastToStream(message.stream_id, { type: 'delete-messages', ids: [messageId] });-- chatServer.forwardToGlobal(message.stream_id, { type: 'delete-messages', ids: [messageId] });-- } else {-- chatServer.broadcastGlobal({ type: 'delete-messages', ids: [messageId] });-- }++ // Broadcast the deletion to every surface that shows the line: the streamer's whole channel++ // room (every live slot, the offline room, a channel popout) and the global feed.++ const delPayload = { type: 'delete-messages', ids: [messageId] };++ const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);++ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);++ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);++ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);++ else chatServer.broadcastGlobal(delPayload);db.logModerationAction({scope_type: 'channel',@@@ -10,6 +10,7 @@* GET /users?after_id&limit → { rows } users projection (paged by id)* POST /users/lookup {ids?, usernames?} → { users }* GET /users/:id/follows → { streamer_ids }++ * GET /subscriber?user_id|subject&streamer_id → { subscriber } (an active channel subscription: sub-only chat)* GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card* GET /streams?after_id&limit, /streams/active, /streams/:id* GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId@@@ -135,6 +136,29 @@ contextRouter.get('/users/:id/follows', (req, res) => {res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });});++// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's++// channel? Live's own rule (db.isActiveSubscriber: status active, current period not over; Billing's++// entitlement under BILLING_AUTHORITY=billing, read fresh here). Network VIP is not a subscription.++// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a++// failed read as "no" for everyone but the room's moderators.++contextRouter.get('/subscriber', async (req, res) => {++ const streamerId = int(req.query.streamer_id);++ let userId = int(req.query.user_id);++ if (!userId && req.query.subject) {++ userId = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1", [String(req.query.subject)])?.user_id || 0;++ if (!userId) return res.json({ subscriber: false, user_id: null, streamer_id: streamerId || null });++ }++ if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');++ try {++ if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {++ await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});++ }++ res.json({ subscriber: !!db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });++ } catch (err) {++ fail(res, 500, 'subscription lookup failed');++ }++});++contextRouter.get('/streams', (req, res) => {const { after, limit } = pageArgs(req);res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });@@@ -341,13 +365,14 @@ effectsRouter.post('/approve-ip', (req, res) => {res.json({ ok: true });});--// /slow persists the channel's slow mode.++// /slow and /subonly persist the channel's slow mode and sub-only mode (Chat enforces the saved values).effectsRouter.post('/channel-settings', (req, res) => {const channelId = int(req.body?.channel_id);const actor = actorOf(int(req.body?.actor_user_id));if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');const fields = {};if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));++ if (req.body.fields && req.body.fields.sub_only !== undefined) fields.sub_only = [true, 1, '1', 'true', 'on'].includes(req.body.fields.sub_only) ? 1 : 0;if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');chatTables.write('upsertChannelModerationSettings', channelId, fields).then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));@@@ -1687,6 +1687,8 @@ function initDb() {if (!cols.includes('sounds_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sounds_mods_only INTEGER DEFAULT 0');// Allow channel mods to edit the streamer's About/panels (off by default).if (!cols.includes('mods_can_edit_about')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN mods_can_edit_about INTEGER DEFAULT 0');++ // Sub-only chat: only active subscribers of the channel (and its moderators) may chat (OpenVibe.Chat enforces it).++ if (!cols.includes('sub_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sub_only INTEGER DEFAULT 0');} catch (e) { console.warn('[DB] channel_moderation_settings columns migration:', e.message); }// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table@@@ -6475,6 +6477,7 @@ function getChannelModerationSettings(channelId) {sound_max_speed: 3.0,sound_min_pitch_cents: -1200,sound_max_pitch_cents: 1200,++ sub_only: 0,};}@@@ -6578,6 +6581,8 @@ function upsertChannelModerationSettings(channelId, fields) {if (fields.tts_max_length !== undefined) {try { run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]); } catch { /* */ }}++ // sub_only the same way (OpenVibe.Chat's database.js writes it identically once it owns the table).++ if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);return getChannelModerationSettings(channelId);}@@@ -546,6 +546,7 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (goal_sound_url TEXT, -- optional override sound for goal-reachedgoal_sound_mime TEXT,emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)++ sub_only INTEGER DEFAULT 0, -- only active subscribers (and the channel's moderators) may chatupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE);nnew file mode 100644@@@ -0,0 +1,133 @@++/**++ * The dashboard's channel moderation routes (server/admin/channel-mod-routes.js), for the chat++ * parity gaps of roadmap WS-I task 6:++ * - the dashboard's slow mode is saved in slow_mode_seconds (it sent slowmode_seconds, which no++ * writer knows, so it was never saved) and sub-only mode is a saved setting like the others;++ * - deleting one message reaches every surface that shows it: the streamer's whole channel room++ * (every live slot, the offline room, a channel popout) and the global feed.++ *++ * The real router on a temp database; only sign-in is stubbed (x-test-user) and the chat server's++ * broadcasts are recorded.++ *++ * node test/channel-moderation-routes.test.js++ */++'use strict';++const assert = require('assert');++const os = require('os');++const path = require('path');++const http = require('http');++++process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++auth.requireAuth = (req, res, next) => {++ const u = db.getUserById(Number(req.headers['x-test-user'] || 0));++ if (!u) return res.status(401).json({ error: 'Authentication required' });++ req.user = u; req.authSource = 'network';++ next();++};++++// What the routes push to browsers.++const chatServer = require('../server/chat/chat-server');++const sent = [];++for (const fn of ['broadcastToStream', 'broadcastToChannelRoom', 'forwardToGlobal', 'forwardToGlobalByChannel', 'broadcastGlobal']) {++ chatServer[fn] = (...args) => { sent.push([fn, ...args.slice(0, -1), args[args.length - 1].type]); };++}++++const addUser = (id, username, role) => raw.prepare("INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)").run(id, username, username, `${username}@x`, role);++addUser(1, 'streamer', 'streamer');++addUser(2, 'moddy', 'user');++addUser(3, 'viewer', 'user');++db.ensureChannel(1);++const channel = db.getChannelByUserId(1);++raw.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, 2, 1);++const streamId = Number(db.createStream({ user_id: 1, channel_id: channel.id, title: 'Live', protocol: 'webrtc' }).lastInsertRowid);++const liveLine = Number(db.saveChatMessage({ stream_id: streamId, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while live' }).lastInsertRowid);++const offlineLine = Number(db.saveChatMessage({ stream_id: null, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while offline' }).lastInsertRowid);++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/channels', require('../server/admin/channel-mod-routes'));++const server = http.createServer(app).listen(0);++++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) } }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ const settings = () => db.getChannelModerationSettings(channel.id);++++ await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {++ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slow_mode_seconds: 7 });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(settings().slow_mode_seconds, 7);++ assert.strictEqual(r.json.settings.slow_mode_seconds, 7);++ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slowmode_seconds: 4 });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(settings().slow_mode_seconds, 4, 'a cached dashboard that still sends slowmode_seconds');++ });++++ await check('sub-only mode is a saved channel setting: the owner and a channel mod may set it, a viewer may not', async () => {++ assert.strictEqual(settings().sub_only, 0, 'off by default');++ assert.strictEqual((await call('PUT', `/api/channels/${channel.id}/moderation`, 3, { sub_only: true })).status, 403);++ assert.strictEqual(settings().sub_only, 0);++ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 2, { sub_only: true });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([settings().sub_only, settings().slow_mode_seconds], [1, 4], 'set; the rest kept');++ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { sub_only: false, followers_only: true });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([settings().sub_only, settings().followers_only], [0, 1]);++ const other = db.ensureChannel(3) || db.getChannelByUserId(3);++ r = await call('PUT', `/api/channels/${other.id}/moderation`, 3, { sub_only: 1 });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(db.getChannelModerationSettings(other.id).sub_only, 1, 'a channel with no settings row yet');++ });++++ await check('deleting a line reaches the whole channel room and the global feed', async () => {++ sent.length = 0;++ let r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${liveLine}/delete`, 2);++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(sent, [++ ['broadcastToChannelRoom', 1, streamId, 'delete-messages'],++ ['forwardToGlobal', streamId, 'delete-messages'],++ ]);++ sent.length = 0;++ r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${offlineLine}/delete`, 2);++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(sent, [++ ['broadcastToChannelRoom', 1, null, 'delete-messages'],++ ['forwardToGlobalByChannel', 1, 'delete-messages'],++ ], 'an offline line: the channel room (popout, channel page) and the global feed');++ assert.strictEqual(db.getChatMessageById(offlineLine).is_deleted, 1);++ });++++ server.close();++ try { db.getDb().close(); } catch { /* */ }++ for (const f of ['', '-wal', '-shm']) { try { require('fs').rmSync(process.env.DB_PATH + f, { force: true }); } catch { /* */ } }++ quiet(failures ? `channel moderation routes: ${failures} failed` : 'channel moderation routes: all checks passed');++ process.exit(failures ? 1 : 0);++})();@@@ -202,6 +202,30 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);++ // Sub-only mode is saved the same way (Chat enforces the saved value); a viewer cannot set it.++ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0, 'off by default');++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { sub_only: 1 } } })).status, 403);++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 1 } } })).status, 200);++ assert.deepStrictEqual([db.getChannelModerationSettings(channel.id).sub_only, db.getChannelModerationSettings(channel.id).slow_mode_seconds], [1, 9], 'sub_only saved, slow mode kept');++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 0 } } })).status, 200);++ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0);++ assert.strictEqual((await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body.settings.sub_only, 0, 'the policy read carries it');++++ // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:++ // by user id or Network subject; a lapsed period or a cancelled row is not one.++ const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });++ assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');++ assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);++ assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() + 86400e3).toISOString() });++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);++ assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');++ assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() - 60e3).toISOString() });++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');++ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'canceled', current_period_end: new Date(Date.now() + 86400e3).toISOString() });++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'not active');const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
@@ -41,9 +41,17 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
@@ -41,9 +41,17 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
### Moderation Tools
### Moderation Tools
- Ban, timeout, unban users
- Ban, timeout, unban users
-- Message deletion (individual or bulk time-range purge)
-- Slow mode (configurable cooldown)
-- Subscribers-only mode
+- Message deletion (individual or bulk time-range purge): the lines leave every surface that showed
+ them (the stream, the rest of the channel room and its popouts, the global feed) and cursor reads
+ name them in `deleted_ids`. `/clear` only clears screens; the lines stay in history
+- Slow mode: the channel's saved `slow_mode_seconds` (dashboard, or `/slow N` / `/slow off`), enforced
+ by OpenVibe.Chat and kept across restarts; moderators are exempt
+- Sub-only mode (`sub_only`: dashboard, or `/subonly` / `/subonly off` by the streamer, channel mods
+ and chat staff): only people with an active channel subscription (`subscriptions`, asked through
+ `GET /internal/chat-context/subscriber`), the streamer, channel mods and chat staff may chat; Network
+ VIP does not count, anonymous viewers cannot chat, and when Live cannot be asked the answer is no
+- Blocks: someone who blocked a person on OpenVibe.Network no longer gets that person's lines, live or
+ in history reads (only their own view; moderation logs show everything)
- Word filtering and opsec filtering
- Word filtering and opsec filtering
### Chat Logs & Admin
### Chat Logs & Admin
@@ -23,10 +23,11 @@ const SLASH_COMMANDS = [
@@ -23,10 +23,11 @@ const SLASH_COMMANDS = [
{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },
{ name: 'me', desc: 'Send an action message', usage: '/me <action>' },
{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },
{ name: 'paste', desc: 'Share a paste in chat', usage: '/paste <content>' },
{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },
{ name: 'slow', desc: 'Set slow mode (mod)', usage: '/slow <seconds>' },
+ { name: 'subonly', desc: 'Sub-only chat on/off (mod)', usage: '/subonly [off]' },
{ name: 'ban', desc: 'Ban a user (mod)', usage: '/ban <user>' },
{ name: 'ban', desc: 'Ban a user (mod)', usage: '/ban <user>' },
{ name: 'unban', desc: 'Unban a user (mod)', usage: '/unban <user>' },
{ name: 'unban', desc: 'Unban a user (mod)', usage: '/unban <user>' },
{ name: 'timeout', desc: 'Timeout a user (mod)', usage: '/timeout <user> [s]' },
{ name: 'timeout', desc: 'Timeout a user (mod)', usage: '/timeout <user> [s]' },
- { name: 'clear', desc: 'Clear chat messages (mod)' },
+ { name: 'clear', desc: 'Clear chat screens; history stays (mod)' },
];
];
/* ── State ────────────────────────────────────────────────────── */
/* ── State ────────────────────────────────────────────────────── */
@@ -98,7 +98,7 @@ function renderDashModerationChannels(channels) {
@@ -98,7 +98,7 @@ function renderDashModerationChannels(channels) {
<div class="dash-mod-section">
<div class="dash-mod-section">
<h5><i class="fa-solid fa-sliders"></i> Chat Settings</h5>
<h5><i class="fa-solid fa-sliders"></i> Chat Settings</h5>
<div class="dash-mod-settings">
<div class="dash-mod-settings">
- <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slowmode_seconds || 0)}"></label>
+ <label><span>Slowmode Seconds</span><input type="number" id="dash-mod-slow-${channel.id}" class="form-input" value="${Number(settings.slow_mode_seconds || 0)}"></label>
<label><span>Max Message Length</span><input type="number" min="1" max="${(currentUser?.role === 'admin') ? 6000 : 4000}" id="dash-mod-maxlen-${channel.id}" class="form-input" value="${Number(settings.max_message_length || 500)}"></label>
<label><span>Max Message Length</span><input type="number" min="1" max="${(currentUser?.role === 'admin') ? 6000 : 4000}" id="dash-mod-maxlen-${channel.id}" class="form-input" value="${Number(settings.max_message_length || 500)}"></label>
<label><span>Max TTS Length</span><input type="number" min="10" max="1200" id="dash-mod-ttslen-${channel.id}" class="form-input" value="${Number(settings.tts_max_length || 200)}"></label>
<label><span>Max TTS Length</span><input type="number" min="10" max="1200" id="dash-mod-ttslen-${channel.id}" class="form-input" value="${Number(settings.tts_max_length || 200)}"></label>
<label><span>Account Age Gate (hours)</span><input type="number" id="dash-mod-age-${channel.id}" class="form-input" value="${Number(settings.account_age_gate_hours || 0)}"></label>
<label><span>Account Age Gate (hours)</span><input type="number" id="dash-mod-age-${channel.id}" class="form-input" value="${Number(settings.account_age_gate_hours || 0)}"></label>
@@ -128,6 +128,7 @@ function renderDashModerationChannels(channels) {
@@ -128,6 +128,7 @@ function renderDashModerationChannels(channels) {
<textarea id="dash-mod-slur-msg-${channel.id}" class="form-input" rows="2" placeholder="Friendly/funny message shown when blocked">${esc(String(settings.slur_filter_nudge_message || ''))}</textarea>
<textarea id="dash-mod-slur-msg-${channel.id}" class="form-input" rows="2" placeholder="Friendly/funny message shown when blocked">${esc(String(settings.slur_filter_nudge_message || ''))}</textarea>
</label>
</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-followers-${channel.id}" ${Number(settings.followers_only || 0) ? 'checked' : ''}> Followers Only</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-followers-${channel.id}" ${Number(settings.followers_only || 0) ? 'checked' : ''}> Followers Only</label>
+ <label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-subonly-${channel.id}" ${Number(settings.sub_only || 0) ? 'checked' : ''}> <span>Sub-only chat (subscribers and mods; also <code>/subonly</code>)</span></label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-${channel.id}" ${Number(settings.soundboard_enabled ?? 1) ? 'checked' : ''}> Allow 101soundboards</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-${channel.id}" ${Number(settings.soundboard_enabled ?? 1) ? 'checked' : ''}> Allow 101soundboards</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-pitch-${channel.id}" ${Number(settings.soundboard_allow_pitch ?? 1) ? 'checked' : ''}> Allow soundboard pitch changes</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-pitch-${channel.id}" ${Number(settings.soundboard_allow_pitch ?? 1) ? 'checked' : ''}> Allow soundboard pitch changes</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-speed-${channel.id}" ${Number(settings.soundboard_allow_speed ?? 1) ? 'checked' : ''}> Allow soundboard speed changes</label>
<label class="staff-inline-toggle"><input type="checkbox" id="dash-mod-soundboard-speed-${channel.id}" ${Number(settings.soundboard_allow_speed ?? 1) ? 'checked' : ''}> Allow soundboard speed changes</label>
@@ -291,7 +292,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
@@ -291,7 +292,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
await api(`/channels/${channelId}/moderation`, {
await api(`/channels/${channelId}/moderation`, {
method: 'PUT',
method: 'PUT',
body: {
body: {
- slowmode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
+ slow_mode_seconds: Number(document.getElementById(`dash-mod-slow-${channelId}`)?.value || 0),
max_message_length: Number(document.getElementById(`dash-mod-maxlen-${channelId}`)?.value || 500),
max_message_length: Number(document.getElementById(`dash-mod-maxlen-${channelId}`)?.value || 500),
tts_max_length: Number(document.getElementById(`dash-mod-ttslen-${channelId}`)?.value || 200),
tts_max_length: Number(document.getElementById(`dash-mod-ttslen-${channelId}`)?.value || 200),
account_age_gate_hours: Number(document.getElementById(`dash-mod-age-${channelId}`)?.value || 0),
account_age_gate_hours: Number(document.getElementById(`dash-mod-age-${channelId}`)?.value || 0),
@@ -311,6 +312,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
@@ -311,6 +312,7 @@ window.dashSaveChannelModerationSettings = async function dashSaveChannelModerat
slur_filter_regexes: String(document.getElementById(`dash-mod-slur-regex-${channelId}`)?.value || ''),
slur_filter_regexes: String(document.getElementById(`dash-mod-slur-regex-${channelId}`)?.value || ''),
slur_filter_nudge_message: String(document.getElementById(`dash-mod-slur-msg-${channelId}`)?.value || ''),
slur_filter_nudge_message: String(document.getElementById(`dash-mod-slur-msg-${channelId}`)?.value || ''),
followers_only: !!document.getElementById(`dash-mod-followers-${channelId}`)?.checked,
followers_only: !!document.getElementById(`dash-mod-followers-${channelId}`)?.checked,
+ sub_only: !!document.getElementById(`dash-mod-subonly-${channelId}`)?.checked,
soundboard_enabled: !!document.getElementById(`dash-mod-soundboard-${channelId}`)?.checked,
soundboard_enabled: !!document.getElementById(`dash-mod-soundboard-${channelId}`)?.checked,
soundboard_allow_pitch: !!document.getElementById(`dash-mod-soundboard-pitch-${channelId}`)?.checked,
soundboard_allow_pitch: !!document.getElementById(`dash-mod-soundboard-pitch-${channelId}`)?.checked,
soundboard_allow_speed: !!document.getElementById(`dash-mod-soundboard-speed-${channelId}`)?.checked,
soundboard_allow_speed: !!document.getElementById(`dash-mod-soundboard-speed-${channelId}`)?.checked,
@@ -175,12 +175,15 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
@@ -175,12 +175,15 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
// The dashboard form sends every field, so for a moderator these are ignored rather than refused.
// The dashboard form sends every field, so for a moderator these are ignored rather than refused.
if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
const settings = await chatTables.write('upsertChannelModerationSettings', req.channel.id, {
const settings = await chatTables.write('upsertChannelModerationSettings', req.channel.id, {
- slow_mode_seconds: req.body.slow_mode_seconds !== undefined
- ? Math.max(0, parseInt(req.body.slow_mode_seconds) || 0) : undefined,
- slowmode_seconds: req.body.slowmode_seconds !== undefined
- ? Math.max(0, parseInt(req.body.slowmode_seconds) || 0) : undefined,
+ // The column is slow_mode_seconds. The dashboard used to send (and read) slowmode_seconds,
+ // which no writer knows, so its slow mode was never saved; a cached dashboard still sends it.
+ slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
+ ? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
followers_only: req.body.followers_only !== undefined
followers_only: req.body.followers_only !== undefined
? parseBoolean(req.body.followers_only, false) : undefined,
? parseBoolean(req.body.followers_only, false) : undefined,
+ // Sub-only chat (enforced by OpenVibe.Chat): active subscribers and the channel's moderators.
+ sub_only: req.body.sub_only !== undefined
+ ? parseBoolean(req.body.sub_only, false) : undefined,
emote_only: req.body.emote_only,
emote_only: req.body.emote_only,
allow_anonymous: req.body.allow_anonymous !== undefined
allow_anonymous: req.body.allow_anonymous !== undefined
? parseBoolean(req.body.allow_anonymous, true) : undefined,
? parseBoolean(req.body.allow_anonymous, true) : undefined,
@@ -341,13 +344,14 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
@@ -341,13 +344,14 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
db.deleteChatMessage(messageId, req.user.id);
db.deleteChatMessage(messageId, req.user.id);
- // Broadcast deletion to connected clients
- if (message.stream_id) {
- chatServer.broadcastToStream(message.stream_id, { type: 'delete-messages', ids: [messageId] });
- chatServer.forwardToGlobal(message.stream_id, { type: 'delete-messages', ids: [messageId] });
- } else {
- chatServer.broadcastGlobal({ type: 'delete-messages', ids: [messageId] });
- }
+ // Broadcast the deletion to every surface that shows the line: the streamer's whole channel
+ // room (every live slot, the offline room, a channel popout) and the global feed.
+ const delPayload = { type: 'delete-messages', ids: [messageId] };
+ const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);
+ if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
+ if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
+ else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
+ else chatServer.broadcastGlobal(delPayload);
db.logModerationAction({
db.logModerationAction({
scope_type: 'channel',
scope_type: 'channel',
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* GET /users?after_id&limit → { rows } users projection (paged by id)
* GET /users?after_id&limit → { rows } users projection (paged by id)
* POST /users/lookup {ids?, usernames?} → { users }
* POST /users/lookup {ids?, usernames?} → { users }
* GET /users/:id/follows → { streamer_ids }
* GET /users/:id/follows → { streamer_ids }
+ * GET /subscriber?user_id|subject&streamer_id → { subscriber } (an active channel subscription: sub-only chat)
* GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card
* GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card
* GET /streams?after_id&limit, /streams/active, /streams/:id
* GET /streams?after_id&limit, /streams/active, /streams/:id
* GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId
* GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId
@@ -135,6 +136,29 @@ contextRouter.get('/users/:id/follows', (req, res) => {
@@ -135,6 +136,29 @@ contextRouter.get('/users/:id/follows', (req, res) => {
res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });
res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });
});
});
+// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's
+// channel? Live's own rule (db.isActiveSubscriber: status active, current period not over; Billing's
+// entitlement under BILLING_AUTHORITY=billing, read fresh here). Network VIP is not a subscription.
+// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a
+// failed read as "no" for everyone but the room's moderators.
+contextRouter.get('/subscriber', async (req, res) => {
+ const streamerId = int(req.query.streamer_id);
+ let userId = int(req.query.user_id);
+ if (!userId && req.query.subject) {
+ userId = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1", [String(req.query.subject)])?.user_id || 0;
+ if (!userId) return res.json({ subscriber: false, user_id: null, streamer_id: streamerId || null });
+ }
+ if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');
+ try {
+ if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {
+ await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});
+ }
+ res.json({ subscriber: !!db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });
+ } catch (err) {
+ fail(res, 500, 'subscription lookup failed');
+ }
+});
+
contextRouter.get('/streams', (req, res) => {
contextRouter.get('/streams', (req, res) => {
const { after, limit } = pageArgs(req);
const { after, limit } = pageArgs(req);
res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
@@ -341,13 +365,14 @@ effectsRouter.post('/approve-ip', (req, res) => {
@@ -341,13 +365,14 @@ effectsRouter.post('/approve-ip', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
-// /slow persists the channel's slow mode.
+// /slow and /subonly persist the channel's slow mode and sub-only mode (Chat enforces the saved values).
effectsRouter.post('/channel-settings', (req, res) => {
effectsRouter.post('/channel-settings', (req, res) => {
const channelId = int(req.body?.channel_id);
const channelId = int(req.body?.channel_id);
const actor = actorOf(int(req.body?.actor_user_id));
const actor = actorOf(int(req.body?.actor_user_id));
if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');
if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');
const fields = {};
const fields = {};
if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));
if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));
+ if (req.body.fields && req.body.fields.sub_only !== undefined) fields.sub_only = [true, 1, '1', 'true', 'on'].includes(req.body.fields.sub_only) ? 1 : 0;
if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');
if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');
chatTables.write('upsertChannelModerationSettings', channelId, fields)
chatTables.write('upsertChannelModerationSettings', channelId, fields)
.then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));
.then(() => res.json({ ok: true }), (err) => fail(res, err.status || 500, err.message));
@@ -1687,6 +1687,8 @@ function initDb() {
@@ -1687,6 +1687,8 @@ function initDb() {
if (!cols.includes('sounds_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sounds_mods_only INTEGER DEFAULT 0');
if (!cols.includes('sounds_mods_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sounds_mods_only INTEGER DEFAULT 0');
// Allow channel mods to edit the streamer's About/panels (off by default).
// Allow channel mods to edit the streamer's About/panels (off by default).
if (!cols.includes('mods_can_edit_about')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN mods_can_edit_about INTEGER DEFAULT 0');
if (!cols.includes('mods_can_edit_about')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN mods_can_edit_about INTEGER DEFAULT 0');
+ // Sub-only chat: only active subscribers of the channel (and its moderators) may chat (OpenVibe.Chat enforces it).
+ if (!cols.includes('sub_only')) database.exec('ALTER TABLE channel_moderation_settings ADD COLUMN sub_only INTEGER DEFAULT 0');
} catch (e) { console.warn('[DB] channel_moderation_settings columns migration:', e.message); }
} catch (e) { console.warn('[DB] channel_moderation_settings columns migration:', e.message); }
// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
// Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
@@ -6475,6 +6477,7 @@ function getChannelModerationSettings(channelId) {
@@ -6475,6 +6477,7 @@ function getChannelModerationSettings(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,
};
};
}
}
@@ -6578,6 +6581,8 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -6578,6 +6581,8 @@ function upsertChannelModerationSettings(channelId, fields) {
if (fields.tts_max_length !== undefined) {
if (fields.tts_max_length !== undefined) {
try { run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]); } catch { /* */ }
try { run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]); } catch { /* */ }
}
}
+ // sub_only the same way (OpenVibe.Chat's database.js writes it identically once it owns the table).
+ if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
return getChannelModerationSettings(channelId);
return getChannelModerationSettings(channelId);
}
}
@@ -546,6 +546,7 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
@@ -546,6 +546,7 @@ CREATE TABLE IF NOT EXISTS channel_moderation_settings (
goal_sound_url TEXT, -- optional override sound for goal-reached
goal_sound_url TEXT, -- optional override sound for goal-reached
goal_sound_mime TEXT,
goal_sound_mime TEXT,
emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
+ sub_only INTEGER DEFAULT 0, -- only active subscribers (and the channel's moderators) may chat
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
);
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+/**
+ * The dashboard's channel moderation routes (server/admin/channel-mod-routes.js), for the chat
+ * parity gaps of roadmap WS-I task 6:
+ * - the dashboard's slow mode is saved in slow_mode_seconds (it sent slowmode_seconds, which no
+ * writer knows, so it was never saved) and sub-only mode is a saved setting like the others;
+ * - deleting one message reaches every surface that shows it: the streamer's whole channel room
+ * (every live slot, the offline room, a channel popout) and the global feed.
+ *
+ * The real router on a temp database; only sign-in is stubbed (x-test-user) and the chat server's
+ * broadcasts are recorded.
+ *
+ * node test/channel-moderation-routes.test.js
+ */
+'use strict';
+const assert = require('assert');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+process.env.DB_PATH = path.join(os.tmpdir(), `ov-chanmod-${process.pid}.db`);
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+auth.requireAuth = (req, res, next) => {
+ const u = db.getUserById(Number(req.headers['x-test-user'] || 0));
+ if (!u) return res.status(401).json({ error: 'Authentication required' });
+ req.user = u; req.authSource = 'network';
+ next();
+};
+
+// What the routes push to browsers.
+const chatServer = require('../server/chat/chat-server');
+const sent = [];
+for (const fn of ['broadcastToStream', 'broadcastToChannelRoom', 'forwardToGlobal', 'forwardToGlobalByChannel', 'broadcastGlobal']) {
+ chatServer[fn] = (...args) => { sent.push([fn, ...args.slice(0, -1), args[args.length - 1].type]); };
+}
+
+const addUser = (id, username, role) => raw.prepare("INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)").run(id, username, username, `${username}@x`, role);
+addUser(1, 'streamer', 'streamer');
+addUser(2, 'moddy', 'user');
+addUser(3, 'viewer', 'user');
+db.ensureChannel(1);
+const channel = db.getChannelByUserId(1);
+raw.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, 2, 1);
+const streamId = Number(db.createStream({ user_id: 1, channel_id: channel.id, title: 'Live', protocol: 'webrtc' }).lastInsertRowid);
+const liveLine = Number(db.saveChatMessage({ stream_id: streamId, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while live' }).lastInsertRowid);
+const offlineLine = Number(db.saveChatMessage({ stream_id: null, channel_user_id: 1, user_id: 3, username: 'viewer', message: 'while offline' }).lastInsertRowid);
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/channels', require('../server/admin/channel-mod-routes'));
+const server = http.createServer(app).listen(0);
+
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = http.request({ port: server.address().port, path: p, method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) } }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ const settings = () => db.getChannelModerationSettings(channel.id);
+
+ await check('the dashboard saves slow mode in slow_mode_seconds (the old slowmode_seconds too)', async () => {
+ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slow_mode_seconds: 7 });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(settings().slow_mode_seconds, 7);
+ assert.strictEqual(r.json.settings.slow_mode_seconds, 7);
+ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { slowmode_seconds: 4 });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(settings().slow_mode_seconds, 4, 'a cached dashboard that still sends slowmode_seconds');
+ });
+
+ await check('sub-only mode is a saved channel setting: the owner and a channel mod may set it, a viewer may not', async () => {
+ assert.strictEqual(settings().sub_only, 0, 'off by default');
+ assert.strictEqual((await call('PUT', `/api/channels/${channel.id}/moderation`, 3, { sub_only: true })).status, 403);
+ assert.strictEqual(settings().sub_only, 0);
+ let r = await call('PUT', `/api/channels/${channel.id}/moderation`, 2, { sub_only: true });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([settings().sub_only, settings().slow_mode_seconds], [1, 4], 'set; the rest kept');
+ r = await call('PUT', `/api/channels/${channel.id}/moderation`, 1, { sub_only: false, followers_only: true });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([settings().sub_only, settings().followers_only], [0, 1]);
+ const other = db.ensureChannel(3) || db.getChannelByUserId(3);
+ r = await call('PUT', `/api/channels/${other.id}/moderation`, 3, { sub_only: 1 });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(db.getChannelModerationSettings(other.id).sub_only, 1, 'a channel with no settings row yet');
+ });
+
+ await check('deleting a line reaches the whole channel room and the global feed', async () => {
+ sent.length = 0;
+ let r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${liveLine}/delete`, 2);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(sent, [
+ ['broadcastToChannelRoom', 1, streamId, 'delete-messages'],
+ ['forwardToGlobal', streamId, 'delete-messages'],
+ ]);
+ sent.length = 0;
+ r = await call('POST', `/api/channels/${channel.id}/moderation/messages/${offlineLine}/delete`, 2);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(sent, [
+ ['broadcastToChannelRoom', 1, null, 'delete-messages'],
+ ['forwardToGlobalByChannel', 1, 'delete-messages'],
+ ], 'an offline line: the channel room (popout, channel page) and the global feed');
+ assert.strictEqual(db.getChatMessageById(offlineLine).is_deleted, 1);
+ });
+
+ server.close();
+ try { db.getDb().close(); } catch { /* */ }
+ for (const f of ['', '-wal', '-shm']) { try { require('fs').rmSync(process.env.DB_PATH + f, { force: true }); } catch { /* */ } }
+ quiet(failures ? `channel moderation routes: ${failures} failed` : 'channel moderation routes: all checks passed');
+ process.exit(failures ? 1 : 0);
+})();
@@ -202,6 +202,30 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -202,6 +202,30 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);
assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);
assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);
+ // Sub-only mode is saved the same way (Chat enforces the saved value); a viewer cannot set it.
+ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0, 'off by default');
+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { sub_only: 1 } } })).status, 403);
+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 1 } } })).status, 200);
+ assert.deepStrictEqual([db.getChannelModerationSettings(channel.id).sub_only, db.getChannelModerationSettings(channel.id).slow_mode_seconds], [1, 9], 'sub_only saved, slow mode kept');
+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { sub_only: 0 } } })).status, 200);
+ assert.strictEqual(db.getChannelModerationSettings(channel.id).sub_only, 0);
+ assert.strictEqual((await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body.settings.sub_only, 0, 'the policy read carries it');
+
+ // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:
+ // by user id or Network subject; a lapsed period or a cancelled row is not one.
+ const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });
+ assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');
+ assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);
+ assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });
+ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
+ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);
+ assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');
+ assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');
+ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');
+ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() - 60e3).toISOString() });
+ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');
+ db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'canceled', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
+ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'not active');
const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');
const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');