openvibers/openvibe.chat Commits
Chat owns chat.tts_defaults, chat.dm_settings and chat.presence_prefs (Contracts 0.41.0, WS-B task 9): one module store per namespace (server/prefs/module-store.js), /api/chat/tts-settings, /dm-settings and /presence beside /preferences, openvibe.chat Settings edits all four; DMs honour new_conversations nobody (existing conversations stay open) and group_invites; user lists count, never name, someone hidden; network.module.updated refreshes them
Changes
@@@ -178,6 +178,16 @@ network.modules.read` and `network.modules.write` on `chat.preferences`) and namevery write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browserIf-Match is strict: 412).++Three more modules work the same way, with `{ settings: { … } }` as the body (openvibe-contracts 0.41.0):++++| Route | Module | Fields | Enforced by |++| --- | --- | --- | --- |++| `/api/chat/tts-settings` | `chat.tts_defaults` v2 | `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources` | Live's chat panel |++| `/api/chat/dm-settings` | `chat.dm_settings` | `new_conversations` (`everyone`/`nobody`), `group_invites`, `previews` | the DM routes. `nobody` refuses a new direct conversation, but an existing one stays open; `group_invites: false` refuses groups (`dm.not_accepting`, `dm.no_group_invites`) |++| `/api/chat/presence` | `chat.presence_prefs` | `show_in_user_list` | the user list. A hidden person is counted in `hiddenCount`, not named |++++openvibe.chat's Settings page edits all four. When Network cannot answer, an enforcement check uses the defaults, so an outage never locks anyone out.++- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; achange made through Network directly (the person's account page) shows at once: its`network.module.updated` reaches the Events consumer, and `prefs.handleEvent()` drops the cached copy@@@ -16,7 +16,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","ws": "^8.18.0"@@@ -1062,9 +1062,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.40.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",-- "integrity": "sha512-/s6JVX88dC2hC2tDmHQcg9vJGZ2RkdMdulIorL0guIzwPq8o0qG7Vv7wk3bVlCSEhzrH62rt6BgRQmD+wmAz2w==",++ "version": "0.41.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","ws": "^8.18.0"@@@ -65,6 +65,8 @@ button[disabled] { opacity: .6; cursor: default; }.oc-conv.oc-unread { border-color: var(--oc-accent); }.oc-form { display: grid; gap: 10px; max-width: 520px; }.oc-check { display: flex; gap: 8px; align-items: center; }++.oc-fieldset { border: 1px solid var(--oc-line); border-radius: 12px; padding: 8px 12px; display: grid; gap: 6px; }++.oc-form h2 { font-size: 1rem; margin: 18px 0 4px; }.oc-form select { margin-left: 8px; }@media (max-width: 560px) {.oc-main { padding: 12px 12px 40px; }@@@ -200,8 +200,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {next();});-- // The person's chat preferences: Network user module chat.preferences (server/prefs/).-- app.use('/api/chat/preferences', require('./prefs/routes'));++ // The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).++ for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…app.use('/api/chat/rooms', require('./rooms/routes'));app.use('/api/chat', require('./chat/routes'));@@@ -407,6 +407,7 @@ class ChatServer {if (user) {if (!client.user || client.user.id === user.id) {client.user = user;++ this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user listclient.tokenIat = session.tokenIat(msg.token);client.anonId = null; // no longer anonymous} else {@@@ -592,12 +593,14 @@ class ChatServer {getUserList(streamId) {const seen = new Set();const logged = [];-- let anonCount = 0;++ let anonCount = 0, hiddenCount = 0;for (const [, c] of this.clients) {if (c.streamId !== streamId) continue;if (c.user) {if (seen.has(c.user.id)) continue;seen.add(c.user.id);++ // chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.++ if (this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }logged.push({username: c.user.username,display_name: c.user.display_name || c.user.username,@@@ -613,7 +616,33 @@ class ChatServer {// Sort: admins first, then mods, then alphabeticalconst rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));-- return { logged, anonCount };++ return { logged, anonCount, hiddenCount };++ }++++ /**++ * Whether a signed-in user asked not to be named in user lists (Network user module++ * chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it++ * in the background and name them until it arrives.++ */++ _hiddenFromUserList(user) {++ const subject = this._subjectOfUser(user);++ if (!subject) return false;++ const { presence } = require('../prefs/stores');++ const cached = presence.peek(subject);++ if (cached) return cached.show_in_user_list === false;++ presence.get(subject).catch(() => {});++ return false;++ }++++ /** Someone changed chat.presence_prefs elsewhere: read it again, then refresh the user lists they are in. */++ presenceChanged(subject) {++ if (!subject) return;++ const { presence } = require('../prefs/stores');++ presence.get(subject).catch(() => {}).then(() => {++ const streams = new Set();++ for (const [, c] of this.clients) if (c.user && this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);++ for (const sid of streams) this.broadcastUsersList(sid);++ });}_parseSlurFilterTerms(rawTerms) {@@@ -18,6 +18,16 @@ const router = express.Router();const { requireAuth } = require('../auth/auth');const dm = require('./dm');const ctx = require('../live-context');++const prefStores = require('../prefs/stores');++++/**++ * The target's chat.dm_settings (Network user module): does this person accept a new direct++ * conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.++ */++async function dmSettingsOf(target) {++ const subject = (target && target.subject_id) || require('../db/database').subjectFor(target && target.id);++ return prefStores.settingsOf(prefStores.dm, subject);++}// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.function publicMessage(m) {@@@ -103,6 +113,14 @@ router.post('/conversations', async (req, res) => {if (dm.isBlockedEither(req.user.id, uid)) {return res.status(403).json({ error: 'Cannot start a conversation with this user' });}++ // Their message settings: a new direct conversation, or a group, only if they accept one.++ const wants = await dmSettingsOf(target);++ if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !dm.findDirectConversation(req.user.id, uid)) {++ return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });++ }++ if (participantIds.length > 2 && wants.group_invites === false) {++ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });++ }}let conversationId;@@@ -273,6 +291,9 @@ router.post('/conversations/:id/participants', async (req, res) => {if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {return res.status(403).json({ error: 'Cannot add this user' });}++ if ((await dmSettingsOf(target)).group_invites === false) {++ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });++ }// Group size limitif (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {@@@ -9,10 +9,11 @@* paths claim the head in deploy_releases, so while both run (compatibility* register C-84) a deploy makes one card whichever arrives first. Older than* 6 hours = ignored:stale.-- * network.module.updated a user-module record changed at Network. Only chat.preferences matters: a-- * revision newer than the cached copy drops it (../prefs/chat-preferences.js-- * handleEvent), so a change made elsewhere shows at once instead of within-- * CHAT_PREFS_TTL_MS. Other namespaces are acknowledged and not recorded.++ * network.module.updated a user-module record changed at Network. Only the chat.* namespaces Chat owns++ * matter (../prefs/stores.js): a revision newer than the cached copy drops it++ * (handleEvent), so a change made elsewhere shows at once instead of within++ * CHAT_PREFS_TTL_MS; a chat.presence_prefs change also refreshes the user lists++ * the person is in. Other namespaces are acknowledged and not recorded.* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached* subscriber-badge answers for that creator are dropped at once* (../vip/badges.js handleEvent) instead of converging by the cache TTL.@@@ -35,7 +36,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');const db = require('../db/database');const { viaProxy } = require('../net/service-auth');const deployNotice = require('../chat/deploy-notice');--const prefs = require('../prefs/chat-preferences');++const prefStores = require('../prefs/stores');const vipBadges = require('../vip/badges');const revocations = require('../auth/revocations');const ctx = require('../live-context');@@@ -71,8 +72,14 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()if (event.event_type === 'network.module.updated') {if (event.source !== 'network') return 'ignored:source';const p = event.payload && typeof event.payload === 'object' ? event.payload : {};-- if (p.namespace !== prefs.NAMESPACE) return 'ignored:namespace';-- return () => (prefs.handleEvent(event) ? 'invalidated' : 'unchanged');++ const store = prefStores.byNamespace.get(p.namespace);++ if (!store) return 'ignored:namespace';++ return () => {++ if (!store.handleEvent(event)) return 'unchanged';++ // Someone changed whether they are named in user lists: fetch it again and refresh the lists they are in.++ if (store === prefStores.presence && chatServer && typeof chatServer.presenceChanged === 'function') chatServer.presenceChanged(p.owner && p.owner.id);++ return 'invalidated';++ };}if (event.event_type === 'network.user.token_valid_after') {// Signed out everywhere, password changed, banned…: refuse this person's older tokens and@@@ -1,141 +1,13 @@/*** A person's chat preferences: the OpenVibe.Network user module `chat.preferences`* (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,-- * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover; Network-- * stores it, validates it, versions it (revision) and announces every change (network.module.updated).-- *-- * get(subject) the record ({ preferences, revision, version }); {} and revision 0 when the-- * person has none. Cached per person for config.prefs.ttlMs; when Network-- * cannot answer, a cached copy is served (stale: true), else 503.-- * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against-- * Network naming the revision read (If-Match); a revision that moved is read-- * again, up to 3 times. With expectedRevision (the browser's If-Match) it is-- * strict instead: 412 when the record moved. A patch that changes nothing-- * writes nothing.-- * handleEvent(envelope) network.module.updated for chat.preferences newer than the cached copy-- * drops it at once. Called by the Events consumer (server/events/consumer.js,-- * Chat's subscription to network.module.updated); without that subscription-- * the TTL bounds how long a change made outside Chat takes to show.-- *-- * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.++ * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover. The store++ * (get, update, handleEvent, invalidate) is ./module-store.js; the other chat.* modules are ./stores.js.*/'use strict';--const { modules } = require('openvibe-contracts');--const config = require('../config');--const client = require('./network-modules');++const { PrefsError, enabled, createModuleStore, _configure } = require('./module-store');--const NAMESPACE = 'chat.preferences';--const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));--const MAX_TRIES = 3;++const store = createModuleStore('chat.preferences', { label: 'preferences', key: 'preferences' });--class PrefsError extends Error {-- constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }--}----const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)--let clock = () => Date.now();----function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }----function remember(subject, rec) {-- cache.delete(subject);-- cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });-- while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);--}----const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, preferences: { ...rec.data }, ...extra });----function unavailable(r) {-- return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);--}----function checkSubject(subject) {-- if (!enabled()) throw new PrefsError(503, 'prefs.disabled', 'chat preferences are not configured on this server');-- if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');--}----/** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */--async function fetchRecord(subject) {-- const r = await client.request('GET', NAMESPACE, subject);-- if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {-- return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };-- }-- if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };-- if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', 'Chat may not read chat.preferences (Network grant missing)');-- throw unavailable(r);--}----async function get(subject, { fresh = false } = {}) {-- checkSubject(subject);-- const hit = cache.get(subject);-- if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);-- try {-- const rec = await fetchRecord(subject);-- remember(subject, rec);-- return view(rec);-- } catch (err) {-- if (hit && err.status === 503) return view(hit, { stale: true });-- throw err;-- }--}----/** A patch's problems: unknown fields, or a value the schema refuses once applied. */--function checkPatch(patch) {-- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', 'send { preferences: { field: value } }');-- const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));-- if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown preference${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: 'not a chat preference' })));--}----const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());----async function update(subject, patch, { expectedRevision } = {}) {-- checkSubject(subject);-- checkPatch(patch);-- for (let i = 0; i < MAX_TRIES; i++) {-- const cur = await fetchRecord(subject);-- if (expectedRevision !== undefined && cur.revision !== expectedRevision) {-- remember(subject, cur);-- throw new PrefsError(412, 'prefs.revision_conflict', `the preferences changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);-- }-- const next = { ...cur.data };-- for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }-- const v = modules.validateData(NAMESPACE, next);-- if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);-- if (same(next, cur.data)) { remember(subject, cur); return view(cur); }-- const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });-- if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {-- const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };-- remember(subject, rec);-- return view(rec);-- }-- if (r.status === 412) {-- if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', 'the preferences changed; read them again');-- continue; // someone else wrote first: read again-- }-- if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);-- if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');-- if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);-- throw unavailable(r);-- }-- throw new PrefsError(409, 'prefs.busy', 'the preferences kept changing; try again');--}----function handleEvent(envelope) {-- if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;-- const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};-- const subject = p.owner && p.owner.id;-- if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;-- if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold-- cache.delete(subject);-- return true;--}----function invalidate(subject) { return cache.delete(subject); }----/** Tests: an injected clock; clears the cache. */--function _configure({ now } = {}) { if (now) clock = now; cache.clear(); }--function _cached(subject) { return cache.get(subject) || null; }----module.exports = { NAMESPACE, FIELDS, PrefsError, enabled, get, update, handleEvent, invalidate, _configure, _cached };++module.exports = { ...store, PrefsError, enabled, _configure };nnew file mode 100644@@@ -0,0 +1,147 @@++/**++ * One OpenVibe.Network user module Chat owns, per person: chat.preferences (./chat-preferences.js), and since++ * openvibe-contracts 0.41.0 chat.tts_defaults, chat.dm_settings and chat.presence_prefs (./stores.js).++ * Network stores each record, validates it against the namespace's schema, versions it (revision) and++ * announces every change (network.module.updated). createModuleStore(namespace, { label }) returns:++ *++ * get(subject) the record ({ data, revision, version }); {} and revision 0 when the person has++ * none. Cached per person for config.prefs.ttlMs; when Network cannot answer, a++ * cached copy is served (stale: true), else 503.++ * peek(subject) the cached data, or null, without asking Network (for synchronous paths).++ * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against++ * Network naming the revision read (If-Match); a revision that moved is read++ * again, up to 3 times. With expectedRevision (the browser's If-Match) it is++ * strict instead: 412 when the record moved. A patch that changes nothing++ * writes nothing.++ * handleEvent(envelope) network.module.updated for this namespace newer than the cached copy drops it++ * at once (the Events consumer, server/events/consumer.js); without that++ * subscription the TTL bounds how long a change made outside Chat takes to show.++ *++ * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.++ */++'use strict';++++const { modules } = require('openvibe-contracts');++const config = require('../config');++const client = require('./network-modules');++++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_TRIES = 3;++++class PrefsError extends Error {++ constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }++}++++let clock = () => Date.now();++function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }++const stores = [];++++function createModuleStore(NAMESPACE, { label = NAMESPACE, key = 'preferences' } = {}) {++ const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));++ const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)++++ function remember(subject, rec) {++ cache.delete(subject);++ cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });++ while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);++ }++++ const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, [key]: { ...rec.data }, ...extra });++++ function unavailable(r) {++ return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);++ }++++ function checkSubject(subject) {++ if (!enabled()) throw new PrefsError(503, 'prefs.disabled', `${label} are not configured on this server`);++ if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');++ }++++ /** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */++ async function fetchRecord(subject) {++ const r = await client.request('GET', NAMESPACE, subject);++ if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {++ return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };++ }++ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };++ if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', `Chat may not read ${NAMESPACE} (Network grant missing)`);++ throw unavailable(r);++ }++++ async function get(subject, { fresh = false } = {}) {++ checkSubject(subject);++ const hit = cache.get(subject);++ if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);++ try {++ const rec = await fetchRecord(subject);++ remember(subject, rec);++ return view(rec);++ } catch (err) {++ if (hit && err.status === 503) return view(hit, { stale: true });++ throw err;++ }++ }++++ /** A patch's problems: unknown fields, or a value the schema refuses once applied. */++ function checkPatch(patch) {++ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', `send { ${key}: { field: value } }`);++ const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));++ if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown field${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: `not in ${NAMESPACE}` })));++ }++++ const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());++++ async function update(subject, patch, { expectedRevision } = {}) {++ checkSubject(subject);++ checkPatch(patch);++ for (let i = 0; i < MAX_TRIES; i++) {++ const cur = await fetchRecord(subject);++ if (expectedRevision !== undefined && cur.revision !== expectedRevision) {++ remember(subject, cur);++ throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);++ }++ const next = { ...cur.data };++ for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }++ const v = modules.validateData(NAMESPACE, next);++ if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);++ if (same(next, cur.data)) { remember(subject, cur); return view(cur); }++ const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });++ if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {++ const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };++ remember(subject, rec);++ return view(rec);++ }++ if (r.status === 412) {++ if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed; read them again`);++ continue; // someone else wrote first: read again++ }++ if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);++ if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');++ if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);++ throw unavailable(r);++ }++ throw new PrefsError(409, 'prefs.busy', `the ${label} kept changing; try again`);++ }++++ function handleEvent(envelope) {++ if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;++ const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};++ const subject = p.owner && p.owner.id;++ if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;++ if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold++ cache.delete(subject);++ return true;++ }++++ function invalidate(subject) { return cache.delete(subject); }++ function peek(subject) { const hit = cache.get(subject); return hit ? { ...hit.data } : null; }++++ const store = { NAMESPACE, FIELDS, get, peek, update, handleEvent, invalidate, _clear: () => cache.clear(), _cached: (subject) => cache.get(subject) || null };++ stores.push(store);++ return store;++}++++/** Tests: an injected clock; clears every store's cache. */++function _configure({ now } = {}) { if (now) clock = now; for (const st of stores) st._clear(); }++++module.exports = { PrefsError, enabled, createModuleStore, _configure };@@@ -1,24 +1,27 @@/**-- * The signed-in person's chat preferences (Network user module chat.preferences, ./chat-preferences.js).-- * Mounted at /api/chat/preferences (nginx already sends /api/chat/ to Chat).++ * The signed-in person's chat settings, one router per Network user module Chat owns (./stores.js).++ * Mounted under /api/chat/ (nginx already sends /api/chat/ to Chat):*-- * GET /api/chat/preferences → { namespace, version, revision, preferences, stale? }-- * PUT /api/chat/preferences { preferences } change these fields (null removes one); optional-- * If-Match: <revision> makes it fail with 412 if they moved++ * /api/chat/preferences chat.preferences body key `preferences`++ * /api/chat/tts-settings chat.tts_defaults body key `settings`++ * /api/chat/dm-settings chat.dm_settings body key `settings`++ * /api/chat/presence chat.presence_prefs body key `settings`*-- * Field names are the namespace's (timestamps, compact, font_scale, show_badges, hide_emotes). ETag is-- * the revision. API tokens (hbt_) may read, never write: preferences are the person's own.++ * GET → { namespace, version, revision, <key>, stale? }++ * PUT { <key>: { field: value | null } } change these fields (null removes one); optional++ * If-Match: <revision> makes it fail with 412 if they moved++ *++ * Field names are the namespace's. ETag is the revision. API tokens (hbt_) may read, never write:++ * settings are the person's own.*/'use strict';const express = require('express');const { requireAuth } = require('../auth/auth');--const prefs = require('./chat-preferences');----const router = express.Router();++const { PrefsError } = require('./module-store');function fail(res, err) {-- if (err instanceof prefs.PrefsError) {++ if (err instanceof PrefsError) {return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });}console.error('[Prefs] unexpected error:', err && err.message);@@@ -27,18 +30,30 @@ function fail(res, err) {const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);--router.get('/', requireAuth, async (req, res) => {-- try { reply(res, await prefs.get(req.user.subject_id)); } catch (err) { fail(res, err); }--});++function routesFor(store, key) {++ const router = express.Router();++ router.get('/', requireAuth, async (req, res) => {++ try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }++ });++ router.put('/', requireAuth, async (req, res) => {++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });++ let expectedRevision;++ if (req.headers['if-match'] !== undefined) {++ expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));++ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });++ }++ try { reply(res, await store.update(req.user.subject_id, req.body && req.body[key], { expectedRevision })); } catch (err) { fail(res, err); }++ });++ return router;++}--router.put('/', requireAuth, async (req, res) => {-- if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat preferences', code: 'prefs.token_denied' });-- let expectedRevision;-- if (req.headers['if-match'] !== undefined) {-- expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));-- if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });-- }-- try { reply(res, await prefs.update(req.user.subject_id, req.body && req.body.preferences, { expectedRevision })); } catch (err) { fail(res, err); }--});++const stores = require('./stores');++/** { mount path under /api/chat → router } */++const ROUTES = {++ preferences: routesFor(stores.preferences, 'preferences'),++ 'tts-settings': routesFor(stores.tts, 'settings'),++ 'dm-settings': routesFor(stores.dm, 'settings'),++ presence: routesFor(stores.presence, 'settings'),++};--module.exports = router;++module.exports = { routesFor, ROUTES };nnew file mode 100644@@@ -0,0 +1,32 @@++/**++ * The user modules Chat owns (openvibe-contracts 0.41.0), one store each (./module-store.js):++ *++ * preferences chat.preferences display (timestamps, compact, font_scale, show_badges, hide_emotes)++ * tts chat.tts_defaults text-to-speech and sounds (send, send_while_live, volume, sounds,++ * sound_volume, sources) — Live's chat panel reads and writes it++ * dm chat.dm_settings new_conversations (everyone | nobody), group_invites, previews —++ * enforced by the DM routes (server/chat/dm-routes.js)++ * presence chat.presence_prefs show_in_user_list — enforced by ChatServer.getUserList()++ */++'use strict';++++const { createModuleStore } = require('./module-store');++const preferences = require('./chat-preferences');++++const tts = createModuleStore('chat.tts_defaults', { label: 'text-to-speech settings', key: 'settings' });++const dm = createModuleStore('chat.dm_settings', { label: 'message settings', key: 'settings' });++const presence = createModuleStore('chat.presence_prefs', { label: 'presence settings', key: 'settings' });++++const all = [preferences, tts, dm, presence];++const byNamespace = new Map(all.map((s) => [s.NAMESPACE, s]));++++/**++ * A person's settings in one store for an enforcement check: the record (cached, else from Network), or++ * {} when there is none or Network cannot answer — the defaults, so an outage never locks anyone out.++ */++async function settingsOf(store, subject) {++ if (!subject) return {};++ try { return (await store.get(subject))[store === preferences ? 'preferences' : 'settings'] || {}; } catch { return {}; }++}++++module.exports = { preferences, tts, dm, presence, all, byNamespace, settingsOf };@@@ -28,6 +28,7 @@ const session = require('../auth/network-session');const historyStore = require('../chat/history-store');const dm = require('../chat/dm');const prefs = require('../prefs/chat-preferences');++const prefStores = require('../prefs/stores');const ctx = require('../live-context');const permissions = require('../auth/permissions');@@@ -230,10 +231,14 @@ ${composer}})(req, res, next);});-- // ── Settings (chat.preferences, the same record Live's chat reads) ──++ // ── Settings: the chat.* user modules (server/prefs/stores.js), the same records Live's chat reads ──++ const TTS_SOURCES = [['native', 'OpenVibe chat'], ['robotstreamer', 'RobotStreamer'], ['kick', 'Kick'], ['youtube', 'YouTube'], ['twitch', 'Twitch']];router.get('/settings', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const p = await prefsOf(actor);++ const sid = actor.user.subject_id;++ const [p, tts, dmSet, pres] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid)]);++ const src = tts.sources || {};++ const volume = (name, label, v) => `<label>${label} <input type="number" name="${name}" min="0" max="100" step="5" value="${Number.isInteger(v) ? v : 80}"></label>`;const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';@@@ -248,6 +253,19 @@ ${box('compact', 'Compact messages', p.compact === true)}${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>++<h2>Messages</h2>++<label>Who can start a conversation with you <select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody (your existing conversations stay open)</option></select></label>++${box('group_invites', 'People can add me to group conversations', dmSet.group_invites !== false)}++${box('previews', 'Show message text in notifications', dmSet.previews !== false)}++<h2>Presence</h2>++${box('show_in_user_list', 'Show my name in chat user lists (when off, you are counted but not named)', pres.show_in_user_list !== false)}++<h2>Text-to-speech and sounds</h2>++${box('tts_send', 'Read my messages aloud where the streamer has text-to-speech on', tts.send !== false)}++${box('tts_send_while_live', 'Also while I am broadcasting', tts.send_while_live !== false)}++${volume('tts_volume', 'Text-to-speech volume', tts.volume)}++${box('tts_sounds', 'Play chat sounds', tts.sounds !== false)}++${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}++<fieldset class="oc-fieldset"><legend>Read messages relayed from</legend>${TTS_SOURCES.map(([k, label]) => box(`tts_src_${k}`, label, src[k] !== false)).join('')}</fieldset><p><button type="submit">Save</button></p></form>`,});@@@ -263,8 +281,20 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}hide_emotes: b.hide_emotes === '1' ? true : null,font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,};-- try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }-- catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }++ const on = (k) => b[k] === '1';++ const pct = (k) => { const n = Math.round(Number(b[k])); return Number.isFinite(n) && n >= 0 && n <= 100 && n !== 80 ? n : null; };++ const sources = Object.fromEntries(TTS_SOURCES.filter(([k]) => !on(`tts_src_${k}`)).map(([k]) => [k, false]));++ const sid = actor.user.subject_id;++ try {++ await prefs.update(sid, patch);++ await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });++ await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });++ await prefStores.tts.update(sid, {++ send: on('tts_send') ? null : false, send_while_live: on('tts_send_while_live') ? null : false, volume: pct('tts_volume'),++ sounds: on('tts_sounds') ? null : false, sound_volume: pct('tts_sound_volume'), sources: Object.keys(sources).length ? sources : null,++ });++ return res.redirect(303, '/settings?saved=1');++ } catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }});// ── Rooms (server/rooms/) ──nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * The chat.* user modules Chat owns since openvibe-contracts 0.41.0 (server/prefs/stores.js):++ *++ * - /api/chat/tts-settings, /api/chat/dm-settings, /api/chat/presence read and write chat.tts_defaults,++ * chat.dm_settings and chat.presence_prefs on Network, like /api/chat/preferences++ * - chat.dm_settings is enforced: "nobody" refuses a new direct conversation (an existing one stays++ * open), group_invites false refuses a group and being added to one++ * - chat.presence_prefs is enforced: someone hidden is counted in the user list, not named; a change++ * made elsewhere (network.module.updated) refreshes the list++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('chat modules');++const ANN = 'usr_01J9ANN0000000000000000AAA';++const BOB = 'usr_01J9B0B0000000000000000AAA';++const CAT = 'usr_01J9CAT0000000000000000AAA';++let h, ann, bob, cat;++++t('boot with a stub Network that knows ann, bob and cat', async () => {++ h = await boot();++ ann = h.addUser('ann', { subject: ANN });++ bob = h.addUser('bob', { subject: BOB });++ cat = h.addUser('cat', { subject: CAT });++ h.netModules.subjects.add(ANN).add(BOB).add(CAT);++});++++t('each module has its route; the body key is settings', async () => {++ for (const [path, ns] of [['tts-settings', 'chat.tts_defaults'], ['dm-settings', 'chat.dm_settings'], ['presence', 'chat.presence_prefs']]) {++ const r = await h.http('GET', `/api/chat/${path}`, { token: ann.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.namespace, ns);++ assert.deepStrictEqual(r.body.settings, {});++ }++ let r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 40, sources: { kick: false } } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.settings, { volume: 40, sources: { kick: false } });++ assert.strictEqual(r.body.version, 2, 'chat.tts_defaults v2');++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 400 } } });++ assert.strictEqual(r.status, 422, 'the schema is enforced');++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { voice: 'x' } } });++ assert.strictEqual(r.status, 422, 'v1 fields are gone');++ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { preferences: { volume: 1 } } });++ assert.strictEqual(r.status, 400, 'these routes take { settings }');++});++++t('chat.dm_settings: nobody refuses a new direct conversation, an existing one stays open', async () => {++ const existing = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });++ assert.strictEqual(existing.status, 200, existing.text);++ let r = await h.http('PUT', '/api/chat/dm-settings', { token: bob.token, body: { settings: { new_conversations: 'nobody', group_invites: false } } });++ assert.strictEqual(r.status, 200, r.text);++ r = await h.http('POST', '/api/dm/conversations', { token: cat.token, body: { user_ids: [bob.id] } });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.not_accepting');++ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });++ assert.strictEqual(r.status, 200, 'the conversation they already have still opens');++ assert.strictEqual(r.body.conversation.id, existing.body.conversation.id);++ r = await h.http('POST', `/api/dm/conversations/${existing.body.conversation.id}/messages`, { token: ann.token, body: { message: 'still here' } });++ assert.strictEqual(r.status, 200, r.text);++});++++t('chat.dm_settings: group_invites false refuses a group and being added to one', async () => {++ let r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id, cat.id], name: 'trio' } });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');++ const dave = h.addUser('dave', { subject: 'usr_01J9DAVE000000000000000AAA' });++ h.netModules.subjects.add('usr_01J9DAVE000000000000000AAA');++ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [cat.id, dave.id], name: 'trio' } });++ assert.strictEqual(r.status, 200, r.text);++ r = await h.http('POST', `/api/dm/conversations/${r.body.conversation.id}/participants`, { token: ann.token, body: { user_id: bob.id } });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');++});++++t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere refreshes the list', async () => {++ let r = await h.http('PUT', '/api/chat/presence', { token: cat.token, body: { settings: { show_in_user_list: false } } });++ assert.strictEqual(r.status, 200, r.text);++ const wsCat = await h.ws({ ip: '198.51.100.21', token: cat.token });++ wsCat.sendJson({ type: 'join', token: cat.token });++ await wsCat.next((m) => m.type === 'auth');++ const wsAnn = await h.ws({ ip: '198.51.100.22', token: ann.token });++ wsAnn.sendJson({ type: 'join', token: ann.token });++ await wsAnn.next((m) => m.type === 'auth');++ wsAnn.sendJson({ type: 'get-users' });++ let list = (await wsAnn.next((m) => m.type === 'users-list')).users;++ assert.ok(list.logged.some((u) => u.username === 'ann'));++ assert.ok(!list.logged.some((u) => u.username === 'cat'), 'cat is not named');++ assert.strictEqual(list.hiddenCount, 1, 'cat is counted');++++ // Cat turns it back on somewhere else (Network announces it): the list names them again.++ const rec = h.netModules.records.get(`chat.presence_prefs|${CAT}`);++ rec.data = { show_in_user_list: true }; rec.revision += 1;++ const stores = require('../server/prefs/stores');++ const chatServer = require('../server/chat/chat-server');++ assert.ok(stores.presence.handleEvent({ event_type: 'network.module.updated', source: 'network', payload: { namespace: 'chat.presence_prefs', owner: { type: 'user', id: CAT }, revision: rec.revision } }));++ chatServer.presenceChanged(CAT);++ list = (await wsAnn.next((m) => m.type === 'users-list' && m.users.logged.some((u) => u.username === 'cat'), 4000)).users;++ assert.strictEqual(list.hiddenCount, 0);++ wsCat.close(); wsAnn.close();++});++++t.run(async () => { if (h) await h.close(); });
@@ -178,6 +178,16 @@ network.modules.read` and `network.modules.write` on `chat.preferences`) and nam
@@ -178,6 +178,16 @@ network.modules.read` and `network.modules.write` on `chat.preferences`) and nam
every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser
every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser
If-Match is strict: 412).
If-Match is strict: 412).
+Three more modules work the same way, with `{ settings: { … } }` as the body (openvibe-contracts 0.41.0):
+
+| Route | Module | Fields | Enforced by |
+| --- | --- | --- | --- |
+| `/api/chat/tts-settings` | `chat.tts_defaults` v2 | `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources` | Live's chat panel |
+| `/api/chat/dm-settings` | `chat.dm_settings` | `new_conversations` (`everyone`/`nobody`), `group_invites`, `previews` | the DM routes. `nobody` refuses a new direct conversation, but an existing one stays open; `group_invites: false` refuses groups (`dm.not_accepting`, `dm.no_group_invites`) |
+| `/api/chat/presence` | `chat.presence_prefs` | `show_in_user_list` | the user list. A hidden person is counted in `hiddenCount`, not named |
+
+openvibe.chat's Settings page edits all four. When Network cannot answer, an enforcement check uses the defaults, so an outage never locks anyone out.
+
- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a
- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a
change made through Network directly (the person's account page) shows at once: its
change made through Network directly (the person's account page) shows at once: its
`network.module.updated` reaches the Events consumer, and `prefs.handleEvent()` drops the cached copy
`network.module.updated` reaches the Events consumer, and `prefs.handleEvent()` drops the cached copy
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -1062,9 +1062,9 @@
@@ -1062,9 +1062,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.40.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
- "integrity": "sha512-/s6JVX88dC2hC2tDmHQcg9vJGZ2RkdMdulIorL0guIzwPq8o0qG7Vv7wk3bVlCSEhzrH62rt6BgRQmD+wmAz2w==",
+ "version": "0.41.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
+ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.40.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -65,6 +65,8 @@ button[disabled] { opacity: .6; cursor: default; }
@@ -65,6 +65,8 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-conv.oc-unread { border-color: var(--oc-accent); }
.oc-conv.oc-unread { border-color: var(--oc-accent); }
.oc-form { display: grid; gap: 10px; max-width: 520px; }
.oc-form { display: grid; gap: 10px; max-width: 520px; }
.oc-check { display: flex; gap: 8px; align-items: center; }
.oc-check { display: flex; gap: 8px; align-items: center; }
+.oc-fieldset { border: 1px solid var(--oc-line); border-radius: 12px; padding: 8px 12px; display: grid; gap: 6px; }
+.oc-form h2 { font-size: 1rem; margin: 18px 0 4px; }
.oc-form select { margin-left: 8px; }
.oc-form select { margin-left: 8px; }
@media (max-width: 560px) {
@media (max-width: 560px) {
.oc-main { padding: 12px 12px 40px; }
.oc-main { padding: 12px 12px 40px; }
@@ -200,8 +200,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -200,8 +200,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
next();
next();
});
});
- // The person's chat preferences: Network user module chat.preferences (server/prefs/).
- app.use('/api/chat/preferences', require('./prefs/routes'));
+ // The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
+ for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
@@ -407,6 +407,7 @@ class ChatServer {
@@ -407,6 +407,7 @@ class ChatServer {
if (user) {
if (user) {
if (!client.user || client.user.id === user.id) {
if (!client.user || client.user.id === user.id) {
client.user = user;
client.user = user;
+ this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
client.tokenIat = session.tokenIat(msg.token);
client.tokenIat = session.tokenIat(msg.token);
client.anonId = null; // no longer anonymous
client.anonId = null; // no longer anonymous
} else {
} else {
@@ -592,12 +593,14 @@ class ChatServer {
@@ -592,12 +593,14 @@ class ChatServer {
getUserList(streamId) {
getUserList(streamId) {
const seen = new Set();
const seen = new Set();
const logged = [];
const logged = [];
- let anonCount = 0;
+ let anonCount = 0, hiddenCount = 0;
for (const [, c] of this.clients) {
for (const [, c] of this.clients) {
if (c.streamId !== streamId) continue;
if (c.streamId !== streamId) continue;
if (c.user) {
if (c.user) {
if (seen.has(c.user.id)) continue;
if (seen.has(c.user.id)) continue;
seen.add(c.user.id);
seen.add(c.user.id);
+ // chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.
+ if (this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }
logged.push({
logged.push({
username: c.user.username,
username: c.user.username,
display_name: c.user.display_name || c.user.username,
display_name: c.user.display_name || c.user.username,
@@ -613,7 +616,33 @@ class ChatServer {
@@ -613,7 +616,33 @@ class ChatServer {
// Sort: admins first, then mods, then alphabetical
// Sort: admins first, then mods, then alphabetical
const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
- return { logged, anonCount };
+ return { logged, anonCount, hiddenCount };
+ }
+
+ /**
+ * Whether a signed-in user asked not to be named in user lists (Network user module
+ * chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it
+ * in the background and name them until it arrives.
+ */
+ _hiddenFromUserList(user) {
+ const subject = this._subjectOfUser(user);
+ if (!subject) return false;
+ const { presence } = require('../prefs/stores');
+ const cached = presence.peek(subject);
+ if (cached) return cached.show_in_user_list === false;
+ presence.get(subject).catch(() => {});
+ return false;
+ }
+
+ /** Someone changed chat.presence_prefs elsewhere: read it again, then refresh the user lists they are in. */
+ presenceChanged(subject) {
+ if (!subject) return;
+ const { presence } = require('../prefs/stores');
+ presence.get(subject).catch(() => {}).then(() => {
+ const streams = new Set();
+ for (const [, c] of this.clients) if (c.user && this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);
+ for (const sid of streams) this.broadcastUsersList(sid);
+ });
}
}
_parseSlurFilterTerms(rawTerms) {
_parseSlurFilterTerms(rawTerms) {
@@ -18,6 +18,16 @@ const router = express.Router();
@@ -18,6 +18,16 @@ const router = express.Router();
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const dm = require('./dm');
const dm = require('./dm');
const ctx = require('../live-context');
const ctx = require('../live-context');
+const prefStores = require('../prefs/stores');
+
+/**
+ * The target's chat.dm_settings (Network user module): does this person accept a new direct
+ * conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.
+ */
+async function dmSettingsOf(target) {
+ const subject = (target && target.subject_id) || require('../db/database').subjectFor(target && target.id);
+ return prefStores.settingsOf(prefStores.dm, subject);
+}
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
function publicMessage(m) {
function publicMessage(m) {
@@ -103,6 +113,14 @@ router.post('/conversations', async (req, res) => {
@@ -103,6 +113,14 @@ router.post('/conversations', async (req, res) => {
if (dm.isBlockedEither(req.user.id, uid)) {
if (dm.isBlockedEither(req.user.id, uid)) {
return res.status(403).json({ error: 'Cannot start a conversation with this user' });
return res.status(403).json({ error: 'Cannot start a conversation with this user' });
}
}
+ // Their message settings: a new direct conversation, or a group, only if they accept one.
+ const wants = await dmSettingsOf(target);
+ if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !dm.findDirectConversation(req.user.id, uid)) {
+ return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });
+ }
+ if (participantIds.length > 2 && wants.group_invites === false) {
+ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });
+ }
}
}
let conversationId;
let conversationId;
@@ -273,6 +291,9 @@ router.post('/conversations/:id/participants', async (req, res) => {
@@ -273,6 +291,9 @@ router.post('/conversations/:id/participants', async (req, res) => {
if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
return res.status(403).json({ error: 'Cannot add this user' });
return res.status(403).json({ error: 'Cannot add this user' });
}
}
+ if ((await dmSettingsOf(target)).group_invites === false) {
+ return res.status(403).json({ error: `${target.display_name || target.username} cannot be added to group conversations`, code: 'dm.no_group_invites' });
+ }
// Group size limit
// Group size limit
if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
@@ -9,10 +9,11 @@
@@ -9,10 +9,11 @@
* paths claim the head in deploy_releases, so while both run (compatibility
* paths claim the head in deploy_releases, so while both run (compatibility
* register C-84) a deploy makes one card whichever arrives first. Older than
* register C-84) a deploy makes one card whichever arrives first. Older than
* 6 hours = ignored:stale.
* 6 hours = ignored:stale.
- * network.module.updated a user-module record changed at Network. Only chat.preferences matters: a
- * revision newer than the cached copy drops it (../prefs/chat-preferences.js
- * handleEvent), so a change made elsewhere shows at once instead of within
- * CHAT_PREFS_TTL_MS. Other namespaces are acknowledged and not recorded.
+ * network.module.updated a user-module record changed at Network. Only the chat.* namespaces Chat owns
+ * matter (../prefs/stores.js): a revision newer than the cached copy drops it
+ * (handleEvent), so a change made elsewhere shows at once instead of within
+ * CHAT_PREFS_TTL_MS; a chat.presence_prefs change also refreshes the user lists
+ * the person is in. Other namespaces are acknowledged and not recorded.
* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached
* vip.membership.changed a membership started, lapsed or was revoked (source vip): the member's cached
* subscriber-badge answers for that creator are dropped at once
* subscriber-badge answers for that creator are dropped at once
* (../vip/badges.js handleEvent) instead of converging by the cache TTL.
* (../vip/badges.js handleEvent) instead of converging by the cache TTL.
@@ -35,7 +36,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');
@@ -35,7 +36,7 @@ const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const db = require('../db/database');
const db = require('../db/database');
const { viaProxy } = require('../net/service-auth');
const { viaProxy } = require('../net/service-auth');
const deployNotice = require('../chat/deploy-notice');
const deployNotice = require('../chat/deploy-notice');
-const prefs = require('../prefs/chat-preferences');
+const prefStores = require('../prefs/stores');
const vipBadges = require('../vip/badges');
const vipBadges = require('../vip/badges');
const revocations = require('../auth/revocations');
const revocations = require('../auth/revocations');
const ctx = require('../live-context');
const ctx = require('../live-context');
@@ -71,8 +72,14 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -71,8 +72,14 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (event.event_type === 'network.module.updated') {
if (event.event_type === 'network.module.updated') {
if (event.source !== 'network') return 'ignored:source';
if (event.source !== 'network') return 'ignored:source';
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
- if (p.namespace !== prefs.NAMESPACE) return 'ignored:namespace';
- return () => (prefs.handleEvent(event) ? 'invalidated' : 'unchanged');
+ const store = prefStores.byNamespace.get(p.namespace);
+ if (!store) return 'ignored:namespace';
+ return () => {
+ if (!store.handleEvent(event)) return 'unchanged';
+ // Someone changed whether they are named in user lists: fetch it again and refresh the lists they are in.
+ if (store === prefStores.presence && chatServer && typeof chatServer.presenceChanged === 'function') chatServer.presenceChanged(p.owner && p.owner.id);
+ return 'invalidated';
+ };
}
}
if (event.event_type === 'network.user.token_valid_after') {
if (event.event_type === 'network.user.token_valid_after') {
// Signed out everywhere, password changed, banned…: refuse this person's older tokens and
// Signed out everywhere, password changed, banned…: refuse this person's older tokens and
@@ -1,141 +1,13 @@
@@ -1,141 +1,13 @@
/**
/**
* A person's chat preferences: the OpenVibe.Network user module `chat.preferences`
* A person's chat preferences: the OpenVibe.Network user module `chat.preferences`
* (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,
* (openvibe-contracts manifests/namespaces/chat.preferences.json, schema v1: timestamps, compact,
- * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover; Network
- * stores it, validates it, versions it (revision) and announces every change (network.module.updated).
- *
- * get(subject) the record ({ preferences, revision, version }); {} and revision 0 when the
- * person has none. Cached per person for config.prefs.ttlMs; when Network
- * cannot answer, a cached copy is served (stale: true), else 503.
- * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against
- * Network naming the revision read (If-Match); a revision that moved is read
- * again, up to 3 times. With expectedRevision (the browser's If-Match) it is
- * strict instead: 412 when the record moved. A patch that changes nothing
- * writes nothing.
- * handleEvent(envelope) network.module.updated for chat.preferences newer than the cached copy
- * drops it at once. Called by the Events consumer (server/events/consumer.js,
- * Chat's subscription to network.module.updated); without that subscription
- * the TTL bounds how long a change made outside Chat takes to show.
- *
- * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.
+ * font_scale, show_badges, hide_emotes). Chat owns the namespace since the Wave 6 cutover. The store
+ * (get, update, handleEvent, invalidate) is ./module-store.js; the other chat.* modules are ./stores.js.
*/
*/
'use strict';
'use strict';
-const { modules } = require('openvibe-contracts');
-const config = require('../config');
-const client = require('./network-modules');
+const { PrefsError, enabled, createModuleStore, _configure } = require('./module-store');
-const NAMESPACE = 'chat.preferences';
-const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));
-const MAX_TRIES = 3;
+const store = createModuleStore('chat.preferences', { label: 'preferences', key: 'preferences' });
-class PrefsError extends Error {
- constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }
-}
-
-const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)
-let clock = () => Date.now();
-
-function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }
-
-function remember(subject, rec) {
- cache.delete(subject);
- cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });
- while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);
-}
-
-const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, preferences: { ...rec.data }, ...extra });
-
-function unavailable(r) {
- return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);
-}
-
-function checkSubject(subject) {
- if (!enabled()) throw new PrefsError(503, 'prefs.disabled', 'chat preferences are not configured on this server');
- if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');
-}
-
-/** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */
-async function fetchRecord(subject) {
- const r = await client.request('GET', NAMESPACE, subject);
- if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {
- return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
- }
- if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };
- if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', 'Chat may not read chat.preferences (Network grant missing)');
- throw unavailable(r);
-}
-
-async function get(subject, { fresh = false } = {}) {
- checkSubject(subject);
- const hit = cache.get(subject);
- if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);
- try {
- const rec = await fetchRecord(subject);
- remember(subject, rec);
- return view(rec);
- } catch (err) {
- if (hit && err.status === 503) return view(hit, { stale: true });
- throw err;
- }
-}
-
-/** A patch's problems: unknown fields, or a value the schema refuses once applied. */
-function checkPatch(patch) {
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', 'send { preferences: { field: value } }');
- const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));
- if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown preference${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: 'not a chat preference' })));
-}
-
-const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
-
-async function update(subject, patch, { expectedRevision } = {}) {
- checkSubject(subject);
- checkPatch(patch);
- for (let i = 0; i < MAX_TRIES; i++) {
- const cur = await fetchRecord(subject);
- if (expectedRevision !== undefined && cur.revision !== expectedRevision) {
- remember(subject, cur);
- throw new PrefsError(412, 'prefs.revision_conflict', `the preferences changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);
- }
- const next = { ...cur.data };
- for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }
- const v = modules.validateData(NAMESPACE, next);
- if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);
- if (same(next, cur.data)) { remember(subject, cur); return view(cur); }
- const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });
- if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {
- const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
- remember(subject, rec);
- return view(rec);
- }
- if (r.status === 412) {
- if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', 'the preferences changed; read them again');
- continue; // someone else wrote first: read again
- }
- if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);
- if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');
- if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);
- throw unavailable(r);
- }
- throw new PrefsError(409, 'prefs.busy', 'the preferences kept changing; try again');
-}
-
-function handleEvent(envelope) {
- if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;
- const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
- const subject = p.owner && p.owner.id;
- if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;
- if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold
- cache.delete(subject);
- return true;
-}
-
-function invalidate(subject) { return cache.delete(subject); }
-
-/** Tests: an injected clock; clears the cache. */
-function _configure({ now } = {}) { if (now) clock = now; cache.clear(); }
-function _cached(subject) { return cache.get(subject) || null; }
-
-module.exports = { NAMESPACE, FIELDS, PrefsError, enabled, get, update, handleEvent, invalidate, _configure, _cached };
+module.exports = { ...store, PrefsError, enabled, _configure };
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+/**
+ * One OpenVibe.Network user module Chat owns, per person: chat.preferences (./chat-preferences.js), and since
+ * openvibe-contracts 0.41.0 chat.tts_defaults, chat.dm_settings and chat.presence_prefs (./stores.js).
+ * Network stores each record, validates it against the namespace's schema, versions it (revision) and
+ * announces every change (network.module.updated). createModuleStore(namespace, { label }) returns:
+ *
+ * get(subject) the record ({ data, revision, version }); {} and revision 0 when the person has
+ * none. Cached per person for config.prefs.ttlMs; when Network cannot answer, a
+ * cached copy is served (stale: true), else 503.
+ * peek(subject) the cached data, or null, without asking Network (for synchronous paths).
+ * update(subject, patch) { field: value | null } (null removes the field). Read-modify-write against
+ * Network naming the revision read (If-Match); a revision that moved is read
+ * again, up to 3 times. With expectedRevision (the browser's If-Match) it is
+ * strict instead: 412 when the record moved. A patch that changes nothing
+ * writes nothing.
+ * handleEvent(envelope) network.module.updated for this namespace newer than the cached copy drops it
+ * at once (the Events consumer, server/events/consumer.js); without that
+ * subscription the TTL bounds how long a change made outside Chat takes to show.
+ *
+ * Only usr_ subjects: Network keeps modules for accounts, and Chat learns a person's subject from Live.
+ */
+'use strict';
+
+const { modules } = require('openvibe-contracts');
+const config = require('../config');
+const client = require('./network-modules');
+
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_TRIES = 3;
+
+class PrefsError extends Error {
+ constructor(status, code, message, errors) { super(message); this.status = status; this.code = code; if (errors) this.errors = errors; }
+}
+
+let clock = () => Date.now();
+function enabled() { return !!(config.prefs.enabled && config.oauth.clientSecret); }
+const stores = [];
+
+function createModuleStore(NAMESPACE, { label = NAMESPACE, key = 'preferences' } = {}) {
+ const FIELDS = Object.freeze(Object.keys(modules.get(NAMESPACE).schema.properties));
+ const cache = new Map(); // subject → { data, revision, version, at } (insertion order = age)
+
+ function remember(subject, rec) {
+ cache.delete(subject);
+ cache.set(subject, { data: rec.data, revision: rec.revision, version: rec.version, at: clock() });
+ while (cache.size > Math.max(1, config.prefs.maxEntries)) cache.delete(cache.keys().next().value);
+ }
+
+ const view = (rec, extra = {}) => ({ namespace: NAMESPACE, version: rec.version, revision: rec.revision, [key]: { ...rec.data }, ...extra });
+
+ function unavailable(r) {
+ return new PrefsError(503, 'prefs.unavailable', r.error ? `OpenVibe.Network is unavailable (${r.error})` : `OpenVibe.Network answered ${r.status}`);
+ }
+
+ function checkSubject(subject) {
+ if (!enabled()) throw new PrefsError(503, 'prefs.disabled', `${label} are not configured on this server`);
+ if (!USER_SUBJECT.test(String(subject || ''))) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not linked to OpenVibe.Network yet');
+ }
+
+ /** The current record from Network ({ data, revision, version }; none yet = {} at revision 0). */
+ async function fetchRecord(subject) {
+ const r = await client.request('GET', NAMESPACE, subject);
+ if (r.status === 200 && r.body && r.body.data && typeof r.body.data === 'object') {
+ return { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
+ }
+ if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return { data: {}, revision: 0, version: modules.get(NAMESPACE).version };
+ if (r.status === 403) throw new PrefsError(503, 'prefs.not_granted', `Chat may not read ${NAMESPACE} (Network grant missing)`);
+ throw unavailable(r);
+ }
+
+ async function get(subject, { fresh = false } = {}) {
+ checkSubject(subject);
+ const hit = cache.get(subject);
+ if (!fresh && hit && clock() - hit.at < config.prefs.ttlMs) return view(hit);
+ try {
+ const rec = await fetchRecord(subject);
+ remember(subject, rec);
+ return view(rec);
+ } catch (err) {
+ if (hit && err.status === 503) return view(hit, { stale: true });
+ throw err;
+ }
+ }
+
+ /** A patch's problems: unknown fields, or a value the schema refuses once applied. */
+ function checkPatch(patch) {
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PrefsError(400, 'prefs.bad_request', `send { ${key}: { field: value } }`);
+ const unknown = Object.keys(patch).filter((k) => !FIELDS.includes(k));
+ if (unknown.length) throw new PrefsError(422, 'prefs.invalid', `unknown field${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`, unknown.map((k) => ({ path: `/${k}`, message: `not in ${NAMESPACE}` })));
+ }
+
+ const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
+
+ async function update(subject, patch, { expectedRevision } = {}) {
+ checkSubject(subject);
+ checkPatch(patch);
+ for (let i = 0; i < MAX_TRIES; i++) {
+ const cur = await fetchRecord(subject);
+ if (expectedRevision !== undefined && cur.revision !== expectedRevision) {
+ remember(subject, cur);
+ throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed (revision ${cur.revision}, not ${expectedRevision}); read them again`);
+ }
+ const next = { ...cur.data };
+ for (const [k, v] of Object.entries(patch)) { if (v === null) delete next[k]; else next[k] = v; }
+ const v = modules.validateData(NAMESPACE, next);
+ if (!v.valid) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', v.errors);
+ if (same(next, cur.data)) { remember(subject, cur); return view(cur); }
+ const r = await client.request('PUT', NAMESPACE, subject, { data: next, revision: cur.revision });
+ if ((r.status === 200 || r.status === 201) && r.body && r.body.data) {
+ const rec = { data: r.body.data, revision: Number(r.body.revision) || 0, version: Number(r.body.version) || 1 };
+ remember(subject, rec);
+ return view(rec);
+ }
+ if (r.status === 412) {
+ if (expectedRevision !== undefined) throw new PrefsError(412, 'prefs.revision_conflict', `the ${label} changed; read them again`);
+ continue; // someone else wrote first: read again
+ }
+ if (r.status === 422) throw new PrefsError(422, 'prefs.invalid', 'these values are not allowed', r.body && r.body.errors);
+ if (r.status === 404) throw new PrefsError(409, 'prefs.subject_unknown', 'this account is not known to OpenVibe.Network');
+ if (r.status === 403 || r.status === 409) throw new PrefsError(503, 'prefs.not_granted', `Network refused the write (${(r.body && r.body.code) || r.status})`);
+ throw unavailable(r);
+ }
+ throw new PrefsError(409, 'prefs.busy', `the ${label} kept changing; try again`);
+ }
+
+ function handleEvent(envelope) {
+ if (!envelope || envelope.event_type !== 'network.module.updated' || envelope.source !== 'network') return false;
+ const p = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
+ const subject = p.owner && p.owner.id;
+ if (p.namespace !== NAMESPACE || !cache.has(subject)) return false;
+ if (!(Number(p.revision) > cache.get(subject).revision)) return false; // ours, or older than what we hold
+ cache.delete(subject);
+ return true;
+ }
+
+ function invalidate(subject) { return cache.delete(subject); }
+ function peek(subject) { const hit = cache.get(subject); return hit ? { ...hit.data } : null; }
+
+ const store = { NAMESPACE, FIELDS, get, peek, update, handleEvent, invalidate, _clear: () => cache.clear(), _cached: (subject) => cache.get(subject) || null };
+ stores.push(store);
+ return store;
+}
+
+/** Tests: an injected clock; clears every store's cache. */
+function _configure({ now } = {}) { if (now) clock = now; for (const st of stores) st._clear(); }
+
+module.exports = { PrefsError, enabled, createModuleStore, _configure };
@@ -1,24 +1,27 @@
@@ -1,24 +1,27 @@
/**
/**
- * The signed-in person's chat preferences (Network user module chat.preferences, ./chat-preferences.js).
- * Mounted at /api/chat/preferences (nginx already sends /api/chat/ to Chat).
+ * The signed-in person's chat settings, one router per Network user module Chat owns (./stores.js).
+ * Mounted under /api/chat/ (nginx already sends /api/chat/ to Chat):
*
*
- * GET /api/chat/preferences → { namespace, version, revision, preferences, stale? }
- * PUT /api/chat/preferences { preferences } change these fields (null removes one); optional
- * If-Match: <revision> makes it fail with 412 if they moved
+ * /api/chat/preferences chat.preferences body key `preferences`
+ * /api/chat/tts-settings chat.tts_defaults body key `settings`
+ * /api/chat/dm-settings chat.dm_settings body key `settings`
+ * /api/chat/presence chat.presence_prefs body key `settings`
*
*
- * Field names are the namespace's (timestamps, compact, font_scale, show_badges, hide_emotes). ETag is
- * the revision. API tokens (hbt_) may read, never write: preferences are the person's own.
+ * GET → { namespace, version, revision, <key>, stale? }
+ * PUT { <key>: { field: value | null } } change these fields (null removes one); optional
+ * If-Match: <revision> makes it fail with 412 if they moved
+ *
+ * Field names are the namespace's. ETag is the revision. API tokens (hbt_) may read, never write:
+ * settings are the person's own.
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
-const prefs = require('./chat-preferences');
-
-const router = express.Router();
+const { PrefsError } = require('./module-store');
function fail(res, err) {
function fail(res, err) {
- if (err instanceof prefs.PrefsError) {
+ if (err instanceof PrefsError) {
return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
}
}
console.error('[Prefs] unexpected error:', err && err.message);
console.error('[Prefs] unexpected error:', err && err.message);
@@ -27,18 +30,30 @@ function fail(res, err) {
@@ -27,18 +30,30 @@ function fail(res, err) {
const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
const reply = (res, out) => res.set('Cache-Control', 'private, no-store').set('ETag', `"${out.revision}"`).json(out);
-router.get('/', requireAuth, async (req, res) => {
- try { reply(res, await prefs.get(req.user.subject_id)); } catch (err) { fail(res, err); }
-});
+function routesFor(store, key) {
+ const router = express.Router();
+ router.get('/', requireAuth, async (req, res) => {
+ try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }
+ });
+ router.put('/', requireAuth, async (req, res) => {
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });
+ let expectedRevision;
+ if (req.headers['if-match'] !== undefined) {
+ expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });
+ }
+ try { reply(res, await store.update(req.user.subject_id, req.body && req.body[key], { expectedRevision })); } catch (err) { fail(res, err); }
+ });
+ return router;
+}
-router.put('/', requireAuth, async (req, res) => {
- if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat preferences', code: 'prefs.token_denied' });
- let expectedRevision;
- if (req.headers['if-match'] !== undefined) {
- expectedRevision = Number(String(req.headers['if-match']).replace(/^W\//, '').replace(/"/g, ''));
- if (!Number.isInteger(expectedRevision) || expectedRevision < 0) return res.status(400).json({ error: 'If-Match must be a revision number', code: 'prefs.bad_revision' });
- }
- try { reply(res, await prefs.update(req.user.subject_id, req.body && req.body.preferences, { expectedRevision })); } catch (err) { fail(res, err); }
-});
+const stores = require('./stores');
+/** { mount path under /api/chat → router } */
+const ROUTES = {
+ preferences: routesFor(stores.preferences, 'preferences'),
+ 'tts-settings': routesFor(stores.tts, 'settings'),
+ 'dm-settings': routesFor(stores.dm, 'settings'),
+ presence: routesFor(stores.presence, 'settings'),
+};
-module.exports = router;
+module.exports = { routesFor, ROUTES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/**
+ * The user modules Chat owns (openvibe-contracts 0.41.0), one store each (./module-store.js):
+ *
+ * preferences chat.preferences display (timestamps, compact, font_scale, show_badges, hide_emotes)
+ * tts chat.tts_defaults text-to-speech and sounds (send, send_while_live, volume, sounds,
+ * sound_volume, sources) — Live's chat panel reads and writes it
+ * dm chat.dm_settings new_conversations (everyone | nobody), group_invites, previews —
+ * enforced by the DM routes (server/chat/dm-routes.js)
+ * presence chat.presence_prefs show_in_user_list — enforced by ChatServer.getUserList()
+ */
+'use strict';
+
+const { createModuleStore } = require('./module-store');
+const preferences = require('./chat-preferences');
+
+const tts = createModuleStore('chat.tts_defaults', { label: 'text-to-speech settings', key: 'settings' });
+const dm = createModuleStore('chat.dm_settings', { label: 'message settings', key: 'settings' });
+const presence = createModuleStore('chat.presence_prefs', { label: 'presence settings', key: 'settings' });
+
+const all = [preferences, tts, dm, presence];
+const byNamespace = new Map(all.map((s) => [s.NAMESPACE, s]));
+
+/**
+ * A person's settings in one store for an enforcement check: the record (cached, else from Network), or
+ * {} when there is none or Network cannot answer — the defaults, so an outage never locks anyone out.
+ */
+async function settingsOf(store, subject) {
+ if (!subject) return {};
+ try { return (await store.get(subject))[store === preferences ? 'preferences' : 'settings'] || {}; } catch { return {}; }
+}
+
+module.exports = { preferences, tts, dm, presence, all, byNamespace, settingsOf };
@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
@@ -28,6 +28,7 @@ const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
const historyStore = require('../chat/history-store');
const dm = require('../chat/dm');
const dm = require('../chat/dm');
const prefs = require('../prefs/chat-preferences');
const prefs = require('../prefs/chat-preferences');
+const prefStores = require('../prefs/stores');
const ctx = require('../live-context');
const ctx = require('../live-context');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
@@ -230,10 +231,14 @@ ${composer}
@@ -230,10 +231,14 @@ ${composer}
})(req, res, next);
})(req, res, next);
});
});
- // ── Settings (chat.preferences, the same record Live's chat reads) ──
+ // ── Settings: the chat.* user modules (server/prefs/stores.js), the same records Live's chat reads ──
+ const TTS_SOURCES = [['native', 'OpenVibe chat'], ['robotstreamer', 'RobotStreamer'], ['kick', 'Kick'], ['youtube', 'YouTube'], ['twitch', 'Twitch']];
router.get('/settings', async (req, res) => {
router.get('/settings', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const p = await prefsOf(actor);
+ const sid = actor.user.subject_id;
+ const [p, tts, dmSet, pres] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid)]);
+ const src = tts.sources || {};
+ const volume = (name, label, v) => `<label>${label} <input type="number" name="${name}" min="0" max="100" step="5" value="${Number.isInteger(v) ? v : 80}"></label>`;
const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;
const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;
const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
@@ -248,6 +253,19 @@ ${box('compact', 'Compact messages', p.compact === true)}
@@ -248,6 +253,19 @@ ${box('compact', 'Compact messages', p.compact === true)}
${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}
${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}
${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>
<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>
+<h2>Messages</h2>
+<label>Who can start a conversation with you <select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody (your existing conversations stay open)</option></select></label>
+${box('group_invites', 'People can add me to group conversations', dmSet.group_invites !== false)}
+${box('previews', 'Show message text in notifications', dmSet.previews !== false)}
+<h2>Presence</h2>
+${box('show_in_user_list', 'Show my name in chat user lists (when off, you are counted but not named)', pres.show_in_user_list !== false)}
+<h2>Text-to-speech and sounds</h2>
+${box('tts_send', 'Read my messages aloud where the streamer has text-to-speech on', tts.send !== false)}
+${box('tts_send_while_live', 'Also while I am broadcasting', tts.send_while_live !== false)}
+${volume('tts_volume', 'Text-to-speech volume', tts.volume)}
+${box('tts_sounds', 'Play chat sounds', tts.sounds !== false)}
+${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
+<fieldset class="oc-fieldset"><legend>Read messages relayed from</legend>${TTS_SOURCES.map(([k, label]) => box(`tts_src_${k}`, label, src[k] !== false)).join('')}</fieldset>
<p><button type="submit">Save</button></p>
<p><button type="submit">Save</button></p>
</form>`,
</form>`,
});
});
@@ -263,8 +281,20 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
@@ -263,8 +281,20 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
hide_emotes: b.hide_emotes === '1' ? true : null,
hide_emotes: b.hide_emotes === '1' ? true : null,
font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,
font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,
};
};
- try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }
- catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
+ const on = (k) => b[k] === '1';
+ const pct = (k) => { const n = Math.round(Number(b[k])); return Number.isFinite(n) && n >= 0 && n <= 100 && n !== 80 ? n : null; };
+ const sources = Object.fromEntries(TTS_SOURCES.filter(([k]) => !on(`tts_src_${k}`)).map(([k]) => [k, false]));
+ const sid = actor.user.subject_id;
+ try {
+ await prefs.update(sid, patch);
+ await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });
+ await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });
+ await prefStores.tts.update(sid, {
+ send: on('tts_send') ? null : false, send_while_live: on('tts_send_while_live') ? null : false, volume: pct('tts_volume'),
+ sounds: on('tts_sounds') ? null : false, sound_volume: pct('tts_sound_volume'), sources: Object.keys(sources).length ? sources : null,
+ });
+ return res.redirect(303, '/settings?saved=1');
+ } catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
});
});
// ── Rooms (server/rooms/) ──
// ── Rooms (server/rooms/) ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * The chat.* user modules Chat owns since openvibe-contracts 0.41.0 (server/prefs/stores.js):
+ *
+ * - /api/chat/tts-settings, /api/chat/dm-settings, /api/chat/presence read and write chat.tts_defaults,
+ * chat.dm_settings and chat.presence_prefs on Network, like /api/chat/preferences
+ * - chat.dm_settings is enforced: "nobody" refuses a new direct conversation (an existing one stays
+ * open), group_invites false refuses a group and being added to one
+ * - chat.presence_prefs is enforced: someone hidden is counted in the user list, not named; a change
+ * made elsewhere (network.module.updated) refreshes the list
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('chat modules');
+const ANN = 'usr_01J9ANN0000000000000000AAA';
+const BOB = 'usr_01J9B0B0000000000000000AAA';
+const CAT = 'usr_01J9CAT0000000000000000AAA';
+let h, ann, bob, cat;
+
+t('boot with a stub Network that knows ann, bob and cat', async () => {
+ h = await boot();
+ ann = h.addUser('ann', { subject: ANN });
+ bob = h.addUser('bob', { subject: BOB });
+ cat = h.addUser('cat', { subject: CAT });
+ h.netModules.subjects.add(ANN).add(BOB).add(CAT);
+});
+
+t('each module has its route; the body key is settings', async () => {
+ for (const [path, ns] of [['tts-settings', 'chat.tts_defaults'], ['dm-settings', 'chat.dm_settings'], ['presence', 'chat.presence_prefs']]) {
+ const r = await h.http('GET', `/api/chat/${path}`, { token: ann.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.namespace, ns);
+ assert.deepStrictEqual(r.body.settings, {});
+ }
+ let r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 40, sources: { kick: false } } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.settings, { volume: 40, sources: { kick: false } });
+ assert.strictEqual(r.body.version, 2, 'chat.tts_defaults v2');
+ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { volume: 400 } } });
+ assert.strictEqual(r.status, 422, 'the schema is enforced');
+ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { settings: { voice: 'x' } } });
+ assert.strictEqual(r.status, 422, 'v1 fields are gone');
+ r = await h.http('PUT', '/api/chat/tts-settings', { token: ann.token, body: { preferences: { volume: 1 } } });
+ assert.strictEqual(r.status, 400, 'these routes take { settings }');
+});
+
+t('chat.dm_settings: nobody refuses a new direct conversation, an existing one stays open', async () => {
+ const existing = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });
+ assert.strictEqual(existing.status, 200, existing.text);
+ let r = await h.http('PUT', '/api/chat/dm-settings', { token: bob.token, body: { settings: { new_conversations: 'nobody', group_invites: false } } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await h.http('POST', '/api/dm/conversations', { token: cat.token, body: { user_ids: [bob.id] } });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.not_accepting');
+ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });
+ assert.strictEqual(r.status, 200, 'the conversation they already have still opens');
+ assert.strictEqual(r.body.conversation.id, existing.body.conversation.id);
+ r = await h.http('POST', `/api/dm/conversations/${existing.body.conversation.id}/messages`, { token: ann.token, body: { message: 'still here' } });
+ assert.strictEqual(r.status, 200, r.text);
+});
+
+t('chat.dm_settings: group_invites false refuses a group and being added to one', async () => {
+ let r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id, cat.id], name: 'trio' } });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');
+ const dave = h.addUser('dave', { subject: 'usr_01J9DAVE000000000000000AAA' });
+ h.netModules.subjects.add('usr_01J9DAVE000000000000000AAA');
+ r = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [cat.id, dave.id], name: 'trio' } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await h.http('POST', `/api/dm/conversations/${r.body.conversation.id}/participants`, { token: ann.token, body: { user_id: bob.id } });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'dm.no_group_invites');
+});
+
+t('chat.presence_prefs: someone hidden is counted, not named; a change elsewhere refreshes the list', async () => {
+ let r = await h.http('PUT', '/api/chat/presence', { token: cat.token, body: { settings: { show_in_user_list: false } } });
+ assert.strictEqual(r.status, 200, r.text);
+ const wsCat = await h.ws({ ip: '198.51.100.21', token: cat.token });
+ wsCat.sendJson({ type: 'join', token: cat.token });
+ await wsCat.next((m) => m.type === 'auth');
+ const wsAnn = await h.ws({ ip: '198.51.100.22', token: ann.token });
+ wsAnn.sendJson({ type: 'join', token: ann.token });
+ await wsAnn.next((m) => m.type === 'auth');
+ wsAnn.sendJson({ type: 'get-users' });
+ let list = (await wsAnn.next((m) => m.type === 'users-list')).users;
+ assert.ok(list.logged.some((u) => u.username === 'ann'));
+ assert.ok(!list.logged.some((u) => u.username === 'cat'), 'cat is not named');
+ assert.strictEqual(list.hiddenCount, 1, 'cat is counted');
+
+ // Cat turns it back on somewhere else (Network announces it): the list names them again.
+ const rec = h.netModules.records.get(`chat.presence_prefs|${CAT}`);
+ rec.data = { show_in_user_list: true }; rec.revision += 1;
+ const stores = require('../server/prefs/stores');
+ const chatServer = require('../server/chat/chat-server');
+ assert.ok(stores.presence.handleEvent({ event_type: 'network.module.updated', source: 'network', payload: { namespace: 'chat.presence_prefs', owner: { type: 'user', id: CAT }, revision: rec.revision } }));
+ chatServer.presenceChanged(CAT);
+ list = (await wsAnn.next((m) => m.type === 'users-list' && m.users.logged.some((u) => u.username === 'cat'), 4000)).users;
+ assert.strictEqual(list.hiddenCount, 0);
+ wsCat.close(); wsAnn.close();
+});
+
+t.run(async () => { if (h) await h.close(); });