openvibers/openvibe.chat Commits
Rooms: call and system kinds, the full role set, Community attachments; openvibe.chat: call rooms, the DM inbox, working with Live down (roadmap WS-I tasks 4 and 8)
Changes
@@@ -89,6 +89,15 @@ SOUNDS_PATH=./data/sounds#CHAT_CALLS=1# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".#CALL_RING_TIMEOUT_MS=45000++# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.++# With TURN_AUTH_SECRET (coturn use-auth-secret, the same value as Live's) credentials expire after an++# hour; otherwise the static pair. Production: the same TURN_URL / TURN_AUTH_SECRET as live.env.++#TURN_URL=turn:turn.openvibe.live++#TURN_AUTH_SECRET=++#TURN_USERNAME=++#TURN_CREDENTIAL=++# Where a room's Community attachments link (room settings page).++#OV_COMMUNITY_URL=https://openvibe.community# Optional diagnostics#DEBUG_DM_DELIVERY=1@@@ -179,6 +179,69 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when itempties or closes. A restart closes what was open (reason `restart`).++### Rooms (openvibe.chat)++++Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and++Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`,++and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people++join. Private rooms: members only; to anyone else they answer exactly like a room that does not exist++(read, join, post, members, attachments, the call). Unread counts per room (`last_read_id`) and++`last_seen_at` per member (moderators see it).++++Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the++voice/video channel `room-<slug>` on `/ws/call`; anyone starts one) and **system** (announcements++written by chat staff, read-only for people; only chat staff create them). Roles, and what they may do++(`rooms.access()` is the one place this is decided):++++| Kind | Role | Read | Post | Join the call | Talk | Moderate | Manage |++| --- | --- | --- | --- | --- | --- | --- | --- |++| community | owner / mod / member / viewer | all | owner, mod, member | – | – | owner, mod | owner |++| call | owner / mod / speaker / participant / viewer | all | owner, mod, speaker, participant | all | owner, mod, speaker | owner, mod | owner |++| system | owner / mod / viewer | all | owner, mod | – | – | owner, mod | owner |++| any | blocked | – | – | – | – | – | – |++| any | no role | public rooms only | – | public call rooms (listening) | – | – | – |++++Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in++calls; a site ban stops posting and talking. Joining gives the kind's role: member, the call room's++`join_role` (participant by default; the owner may pick speaker or viewer) or viewer. The owner (and chat++staff) appoint mods; owners and mods set every other role; roles outside the room's kind are refused++(`rooms.role_kind`). Role changes on someone else are logged (`room_block`, `room_unblock`,++`room_mod_add|remove`, `room_speaker_add|remove`) through the moderation log. A change reaches open sockets at++once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the++room (below).++++**Call rooms.** The call server makes the channel `room-<slug>` on first use and keeps it; it is not in Live's++voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants,++viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the++camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead).++Peers see `roomRole` / `canTalk` on each participant. Promoting, demoting or blocking someone sends them++`room-role` and the force-mute state, or drops them; a ban inside the call is also the room's block; making++the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by++clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call++client (`public/web/call.js`), shown when `CHAT_CALLS=1`; ICE comes from `GET /api/chat/ice-servers`++(`server/net/turn.js`, Live's `turn.js` logic: STUN, plus TURN when `TURN_URL` is set with++`TURN_AUTH_SECRET` or `TURN_USERNAME`/`TURN_CREDENTIAL`).++++**Attached to a Community space.** `POST /api/chat/rooms/:slug/attachments { service: "community",++resource: <space slug>, title? }` (201 created, 200 already there), `DELETE++/api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who++manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused);++the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own++side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is++involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public++claim on the room page.++++**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox++(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),++`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block++the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation++counts unread messages and room messages. Everything is server-rendered and works without JavaScript.++++**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token++and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and++the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the++forms, the core APIs and the socket with Live refusing connections and with Live hanging.++### VIP member badgesA member's messages in a creator's room (stream or offline channel chat) carry that creator's VIPnnew file mode 100644@@@ -0,0 +1,191 @@++/* openvibe.chat call rooms: the room's voice call in the page. Audio only, peer to peer (a full mesh, as++ Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol of++ server/calls/call-server.js: the newcomer offers to everyone already in, the others answer. The room's++ roles decide who talks (the server force-mutes listeners; this client never sends a microphone it may++ not use). Names are set with textContent. The room's chat works without this file. */++(function () {++ 'use strict';++ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;++ if (!cfg || !cfg.call || !cfg.call.join || !window.RTCPeerConnection || !window.WebSocket) return;++ var $ = function (id) { return document.getElementById(id); };++ var joinBtn = $('oc-call-join'), muteBtn = $('oc-call-mute'), leaveBtn = $('oc-call-leave');++ var statusEl = $('oc-call-status'), peopleEl = $('oc-call-people'), countEl = $('oc-call-count'), howEl = $('oc-call-how');++ if (!joinBtn) return;++++ var canTalk = !!cfg.call.talk;++ var ws = null, myPeerId = null, localStream = null, muted = false, forceMuted = !canTalk, joined = false, connecting = false;++ var attempt = 0; // each join; a teardown makes an attempt still starting stale++ var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];++ var peers = new Map(); // peerId → { pc, audio, pending: [candidates] }++ var people = new Map(); // peerId → participant info from the server++++ function status(text) { if (statusEl) statusEl.textContent = text || ''; }++ function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++++ function render() {++ if (!peopleEl) return;++ peopleEl.textContent = '';++ if (!people.size) { peopleEl.appendChild(el('li', 'oc-muted', 'Nobody is in the call yet.')); }++ people.forEach(function (p, id) {++ var li = el('li', null, (p.displayName || p.username || p.anonId || 'Guest') + (id === myPeerId ? ' (you)' : ''));++ var state = p.canTalk === false || p.forceMuted ? 'listening' : p.muted ? 'muted' : '';++ if (state) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted', '(' + state + ')')); }++ peopleEl.appendChild(li);++ });++ if (countEl) countEl.textContent = people.size ? '· ' + people.size + ' in' : '';++ }++ function setButtons() {++ joinBtn.hidden = joined;++ joinBtn.disabled = connecting;++ leaveBtn.hidden = !joined;++ muteBtn.hidden = !joined || !localStream;++ muteBtn.disabled = forceMuted;++ muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';++ muteBtn.setAttribute('aria-pressed', muted || forceMuted ? 'true' : 'false');++ }++ function applyMute() {++ if (localStream) localStream.getAudioTracks().forEach(function (t) { t.enabled = !(muted || forceMuted); });++ setButtons();++ }++++ function closePeer(id) {++ var p = peers.get(id);++ if (!p) return;++ try { p.pc.close(); } catch (e) { /* */ }++ if (p.audio) { p.audio.srcObject = null; p.audio.remove(); }++ peers.delete(id);++ }++ function createPeer(id, initiator) {++ closePeer(id);++ var pc = new RTCPeerConnection({ iceServers: iceServers });++ var p = { pc: pc, audio: null, pending: [] };++ peers.set(id, p);++ if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });++ else if (initiator) pc.addTransceiver('audio', { direction: 'recvonly' });++ pc.onicecandidate = function (e) { if (e.candidate) send({ type: 'ice-candidate', targetPeerId: id, candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate }); };++ pc.ontrack = function (e) {++ if (!p.audio) { p.audio = document.createElement('audio'); p.audio.autoplay = true; p.audio.hidden = true; document.body.appendChild(p.audio); }++ p.audio.srcObject = (e.streams && e.streams[0]) || new MediaStream([e.track]);++ };++ pc.onconnectionstatechange = function () { if (pc.connectionState === 'failed') status('The connection to someone in the call failed. Leave and join again to retry.'); };++ if (initiator) {++ pc.createOffer().then(function (o) { return pc.setLocalDescription(o); })++ .then(function () { send({ type: 'offer', targetPeerId: id, sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } }); })++ .catch(function () { status('Could not start the call with someone.'); });++ }++ return p;++ }++ function flush(p) { var c; while ((c = p.pending.shift())) p.pc.addIceCandidate(c).catch(function () {}); }++++ function onMessage(m) {++ switch (m.type) {++ case 'welcome':++ myPeerId = m.peerId; joined = true; connecting = false;++ people.clear();++ (m.participants || []).forEach(function (p) { people.set(p.peerId, p); });++ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) { rejoin(m.canTalk); return; }++ (m.participants || []).forEach(function (p) { if (p.peerId !== myPeerId) createPeer(p.peerId, true); });++ status(canTalk ? 'You are in the call.' : 'You are listening.');++ render(); applyMute();++ break;++ case 'peer-joined':++ people.set(m.peerId, m); createPeer(m.peerId, false); render();++ break;++ case 'peer-left':++ people.delete(m.peerId); closePeer(m.peerId); render();++ break;++ case 'peer-updated':++ people.set(m.peerId, Object.assign(people.get(m.peerId) || {}, m)); render();++ break;++ case 'peer-muted': case 'peer-force-muted': {++ var who = people.get(m.peerId);++ if (who) { if (m.type === 'peer-muted') who.muted = !!m.muted; else who.forceMuted = !!m.forceMuted; render(); }++ break;++ }++ case 'offer': {++ var p = peers.get(m.fromPeerId) || createPeer(m.fromPeerId, false);++ p.pc.setRemoteDescription(m.sdp)++ .then(function () { flush(p); return p.pc.createAnswer(); })++ .then(function (a) { return p.pc.setLocalDescription(a); })++ .then(function () { send({ type: 'answer', targetPeerId: m.fromPeerId, sdp: { type: p.pc.localDescription.type, sdp: p.pc.localDescription.sdp } }); })++ .catch(function () { status('Could not connect to someone in the call.'); });++ break;++ }++ case 'answer': {++ var q = peers.get(m.fromPeerId);++ if (q) q.pc.setRemoteDescription(m.sdp).then(function () { flush(q); }).catch(function () {});++ break;++ }++ case 'ice-candidate': {++ var r = peers.get(m.fromPeerId);++ if (!r || !m.candidate) break;++ if (r.pc.remoteDescription) r.pc.addIceCandidate(m.candidate).catch(function () {});++ else r.pending.push(m.candidate);++ break;++ }++ case 'force-muted':++ forceMuted = !!m.forceMuted; applyMute();++ break;++ case 'room-role':++ // A moderator changed your role: a new speaker needs the microphone, a new listener gives it up.++ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) rejoin(m.canTalk);++ break;++ case 'kicked': teardown('You were removed from the call.'); break;++ case 'banned': teardown('You were banned from this call.'); break;++ case 'call-ended': teardown('The call ended.'); break;++ case 'replaced': teardown('You joined this call from another tab.'); break;++ case 'error': teardown(m.message || 'The call is not available.'); break;++ default: break;++ }++ }++++ function teardown(text) {++ attempt++;++ connecting = false;++ peers.forEach(function (p, id) { closePeer(id); });++ people.clear();++ if (ws) { try { ws.close(); } catch (e) { /* */ } ws = null; }++ if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; }++ joined = false; myPeerId = null;++ render(); setButtons();++ if (text != null) status(text);++ }++ function rejoin(talk) {++ canTalk = talk; forceMuted = !talk;++ if (howEl) howEl.textContent = talk ? 'You can talk in this call.' : 'You are listening. The owner or a moderator can make you a speaker.';++ teardown(talk ? 'You can talk now. Joining with your microphone…' : 'You are a listener now. Rejoining…');++ join();++ }++++ function join() {++ var mine = ++attempt;++ connecting = true; setButtons();++ status('Joining…');++ var ice = fetch('/api/chat/ice-servers', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { if (d && Array.isArray(d.iceServers) && d.iceServers.length) iceServers = d.iceServers; })++ .catch(function () { /* STUN only */ });++ var mic = canTalk && navigator.mediaDevices && navigator.mediaDevices.getUserMedia++ ? navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })++ .then(function (s) { if (mine === attempt) localStream = s; else s.getTracks().forEach(function (t) { t.stop(); }); }, function () { status('No microphone: you will listen until you allow it and join again.'); })++ : Promise.resolve();++ Promise.all([ice, mic]).then(function () {++ if (mine !== attempt) return;++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));++ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };++ var sock = ws;++ ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };++ });++ }++++ joinBtn.hidden = false;++ joinBtn.addEventListener('click', join);++ leaveBtn.addEventListener('click', function () { teardown('You left the call.'); });++ muteBtn.addEventListener('click', function () {++ if (forceMuted) return;++ muted = !muted; applyMute();++ send({ type: 'mute', muted: muted });++ });++ window.addEventListener('pagehide', function () { if (joined) teardown(null); });++})();@@@ -91,3 +91,22 @@ button[disabled] { opacity: .6; cursor: default; }.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }.oc-members form { margin-left: auto; }h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }++/* Room kinds, calls, the inbox */++.oc-kind-call { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }++.oc-kind-system { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }++.oc-call { margin: 12px 0; padding: 10px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; }++.oc-call h2 { margin: 0 0 4px; font-size: 1.05rem; }++.oc-call-people { list-style: none; margin: 6px 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; }++.oc-call-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }++.oc-call-controls button[hidden] { display: none; }++.oc-call-status:empty { display: none; }++.oc-tools { display: block; margin-top: 8px; }++.oc-blocked { margin-top: 20px; }++.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }++.oc-blocked .oc-members { margin-top: 8px; }++.oc-members li form.oc-inline { margin-left: auto; }++.oc-members select { max-width: 100%; }++@media (max-width: 560px) {++ .oc-members form { margin-left: 0; width: 100%; display: flex; flex-wrap: wrap; gap: 6px; }++ .oc-new select { flex: 1 1 140px; }++}@@@ -1,6 +1,6 @@--/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few-- seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text-- is always set with textContent, never parsed as HTML. */++/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs and the inbox++ refresh every few seconds. Pages work without this file (server-rendered, forms post); it only adds++ liveness. User text is always set with textContent, never parsed as HTML. */(function () {'use strict';var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;@@@ -211,6 +211,9 @@showError(m.message);} else if (m.type === 'room_left') {showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);++ } else if (m.type === 'room_access' && m.room === cfg.room && m.can) {++ // Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.++ if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }} else if (m.type === 'auth_revoked') {showError('You were signed out. Sign in again to keep chatting.');}@@@ -238,6 +241,42 @@});}++ // ── The inbox: conversations and unread counts, refreshed while the page is open ──++ function convItem(c, me) {++ var li = el('li', 'oc-conv' + (c.unread_count ? ' oc-unread' : ''));++ var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);++ var others = (c.participants || []).filter(function (p) { return p.id !== me; });++ a.appendChild(el('strong', null, c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you'));++ if (c.unread_count) { a.appendChild(document.createTextNode(' ')); a.appendChild(el('span', 'oc-badge', c.unread_count + ' new')); }++ a.appendChild(el('span', 'oc-muted oc-last', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet'));++ li.appendChild(a);++ if (c.last_message_at) {++ var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);++ if (!isNaN(d)) { var t = el('time', 'oc-time', d.toISOString().slice(0, 10)); t.dateTime = d.toISOString(); li.appendChild(t); }++ }++ return li;++ }++ function startInbox() {++ var list = document.getElementById('oc-convs');++ var status = document.getElementById('oc-inbox-status');++ var me = Number(cfg.me) || null;++ function poll() {++ if (document.hidden || !list) return;++ fetch('/api/dm/conversations', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) {++ if (!d || !Array.isArray(d.conversations)) return;++ var unread = 0;++ list.textContent = '';++ d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });++ if (!d.conversations.length) list.appendChild(el('li', 'oc-muted', 'No conversations yet.'));++ if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';++ }).catch(function () {});++ }++ setInterval(poll, 15000);++ document.addEventListener('visibilitychange', poll);++ }++if (input) input.addEventListener('keydown', function (e) {if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }});@@@ -245,4 +284,5 @@if (cfg.view === 'global') startGlobal();else if (cfg.view === 'dm') startDm();else if (cfg.view === 'room') startRoom();++ else if (cfg.view === 'inbox') startInbox();})();@@@ -209,6 +209,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServefor (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'));++ // ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.++ app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());app.use('/api/chat', require('./chat/routes'));app.use('/api/dm', require('./chat/dm-routes'));app.use('/api/tts', require('./chat/tts-routes'));@@@ -24,6 +24,18 @@* 'mic' — Microphone only* 'mic+cam' — Mic + optional camera* 'cam+mic' — Both mic and camera++ *++ * Call rooms (roadmap WS-I task 4, ../rooms/rooms.js): a room of kind `call` has the channel++ * `room-<slug>`, made when someone first connects to it and kept (permanent) while the room exists.++ * The room decides who is in: a person who may not read the room is refused (a private room looks++ * missing), blocked people are refused, and the room's roles decide who talks: owner, mods and++ * speakers talk; participants, viewers and people without a role (public rooms, anonymous too) join++ * listen-only — force-muted with the camera forced off, and their own unmute is ignored. The room's++ * owner and mods (and chat staff) moderate the call. A role change reaches the call at once++ * (applyRoomAccess: a `room-role` frame, then the force-mute state); a ban in the call also blocks the++ * person in the room. Room channels are not in the voice-channel list (Live's sidebar): openvibe.chat++ * lists rooms. Signalling only: media is peer to peer, so listen-only is enforced by the clients++ * honouring force-mute (as for every force-mute here), and every peer sees who may talk.*/'use strict';@@@ -34,6 +46,7 @@ const { extractWsToken, authenticateWs } = require('../auth/auth');const permissions = require('../auth/permissions');const chatServer = require('../chat/chat-server');const lifecycle = require('./lifecycle');++const rooms = require('../rooms/rooms');// Cosmetics are Live's (monetization/cosmetics); read through live-context, warmed when a socket joins.const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };@@@ -43,6 +56,7 @@ const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never onconst KICK_COOLDOWN_MS = 60 * 1000;const PUBLIC_CHANNEL_ID = 'public';const MODES = ['mic', 'mic+cam', 'cam+mic'];++const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;class CallServer {constructor() {@@@ -124,6 +138,7 @@ class CallServer {listChannels(viewer = null) {const result = [];for (const [id, ch] of this.channels) {++ if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebarif (ch.private && !this._canSeePrivate(ch, viewer)) continue;const room = this.rooms.get(id);const participants = [];@@@ -134,6 +149,7 @@ class CallServer {}_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }_canSeePrivate(ch, viewer) {++ if (ch.roomSlug) { const room = this._roomOf(ch.id); return !!room && rooms.access(room, viewer).read; }if (!ch.private) return true;if (!viewer) return false;if (permissions.can(viewer, 'staff.moderation.calls')) return true;@@@ -161,6 +177,29 @@ class CallServer {if (this._notifyTimer.unref) this._notifyTimer.unref();}++ /** The call room behind a `room-<slug>` channel id, or null. */++ _roomOf(channelId) {++ const m = ROOM_CHANNEL.exec(String(channelId || ''));++ if (!m) return null;++ const room = rooms.bySlug(m[1]);++ return room && room.kind === 'call' ? room : null;++ }++++ /** The channel of a call room, made on first use and named after the room. → channel | null */++ ensureRoomChannel(channelId) {++ const room = this._roomOf(channelId);++ if (!room) return null;++ let ch = this.channels.get(channelId);++ if (!ch) {++ ch = {++ id: channelId, name: room.name, mode: 'mic+cam', createdBy: room.owner_id, streamId: null, permanent: true,++ roomSlug: room.slug, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,++ };++ this.channels.set(channelId, ch);++ } else { ch.name = room.name; ch.createdBy = room.owner_id; }++ return ch;++ }++getChannel(channelId, viewer = null) {const ch = this.channels.get(channelId);if (!ch || !this._canSeePrivate(ch, viewer)) return null;@@@ -252,6 +291,7 @@ class CallServer {forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,speaking: info.speaking || false,nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,++ ...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),};}@@@ -271,6 +311,7 @@ class CallServer {// Legacy: bare number → stream-<N>const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;++ if (!this.channels.get(resolvedId)) this.ensureRoomChannel(resolvedId);if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive@@@ -314,6 +355,16 @@ class CallServer {if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }++ // A call room: the room's roles decide who is in and who talks.++ let roomAccess = null;++ if (channel.roomSlug) {++ const room = this._roomOf(resolvedId);++ roomAccess = room ? rooms.access(room, user) : null;++ if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }++ if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }++ }++ const listenOnly = !!(roomAccess && !roomAccess.talk);++const peerId = this._generatePeerId();const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);@@@ -355,7 +406,10 @@ class CallServer {const maxP = channel.maxParticipants || MAX_PARTICIPANTS;if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }-- const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };++ const clientInfo = {++ ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,++ isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),++ };this.clients.set(ws, { channelId: resolvedId, peerId });room.set(peerId, clientInfo);@@@ -381,7 +435,12 @@ class CallServer {participants,isStreamer: isStreamer || isChannelCreator,canModerate,++ ...(channel.roomSlug ? { room: channel.roomSlug, roomRole: clientInfo.roomRole, canTalk: !listenOnly } : {}),}));++ if (listenOnly) {++ ws.send(JSON.stringify({ type: 'force-muted', forceMuted: true }));++ ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: true }));++ }const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }@@@ -409,6 +468,7 @@ class CallServer {if (!user) return false;if (permissions.can(user, 'staff.moderation.calls')) return true;const ch = this.channels.get(channelId);++ if (ch?.roomSlug) { const room = this._roomOf(channelId); return !!room && rooms.access(room, user).moderate; }if (ch?.createdBy === user.id) return true;if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);return false;@@@ -429,6 +489,7 @@ class CallServer {}case 'mute': {const c = room.get(peerId); if (!c) break;++ if (c.listenOnly && !msg.muted) break; // a listener in a call room stays mutedc.muted = !!msg.muted;const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }@@@ -437,6 +498,7 @@ class CallServer {}case 'camera-off': {const c = room.get(peerId); if (!c) break;++ if (c.listenOnly && !msg.cameraOff) break;c.cameraOff = !!msg.cameraOff;const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }@@@ -469,10 +531,16 @@ class CallServer {if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;}const ch = this.channels.get(channelId);++ if (ch?.roomSlug) {++ const callRoom = this._roomOf(channelId);++ const a = callRoom ? rooms.access(callRoom, user) : null;++ if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }++ }c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);c.isChannelCreator = !!(user && ch?.createdBy === user.id);c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }++ if (ch?.roomSlug) { this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }const pInfo = this._buildParticipantInfo(peerId, c);if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({type: 'self-updated',@@@ -487,6 +555,7 @@ class CallServer {case 'force-mute': {const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;++ if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room insteadtarget.forceMuted = !!msg.forceMuted;if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });@@@ -496,6 +565,7 @@ class CallServer {case 'force-camera-off': {const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;++ if (target.listenOnly && !msg.forceCameraOff) break;target.forceCameraOff = !!msg.forceCameraOff;if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });@@@ -521,6 +591,11 @@ class CallServer {if (target.user?.id) {banSet.add(target.user.id); // backward compatibility with existing entriesbanSet.add(`u:${target.user.id}`);++ // In a call room the ban is the room's block, so it outlives this process.++ const callRoom = this._roomOf(channelId);++ if (callRoom && sender.user) {++ try { rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }++ }} else {if (target.anonId) banSet.add(`a:${target.anonId}`);if (target.ip) banSet.add(`ip:${target.ip}`);@@@ -596,6 +671,60 @@ class CallServer {getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }++ /**++ * A call room's roles or visibility changed (../rooms/routes.js, the site): everyone in its call gets++ * what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer++ * read the room (blocked, removed from a private room) is dropped. → peers changed++ */++ applyRoomAccess(room) {++ if (!room || room.kind !== 'call') return 0;++ const channelId = `room-${room.slug}`;++ const ch = this.channels.get(channelId);++ if (ch) { ch.name = room.name; ch.createdBy = room.owner_id; }++ const r = this.rooms.get(channelId);++ if (!r) return 0;++ let changed = 0;++ for (const [pid, info] of [...r]) if (this._applyRoomAccessTo(channelId, pid, info, room)) changed++;++ return changed;++ }++++ /** One peer of a call room against the room's current roles. → whether anything changed */++ _applyRoomAccessTo(channelId, peerId, info, room = null) {++ const callRoom = room || this._roomOf(channelId);++ const a = callRoom ? rooms.access(callRoom, info.user || null) : null;++ const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };++ if (!a || !a.read || !a.join) {++ send({ type: 'error', message: 'You can no longer join this call' });++ this._handleDisconnect(info.ws, channelId, peerId);++ try { info.ws.close(); } catch { /* */ }++ return true;++ }++ const listenOnly = !a.talk;++ if (info.listenOnly === listenOnly && info.roomRole === a.role) return false;++ info.roomRole = a.role;++ info.listenOnly = listenOnly;++ info.isChannelCreator = !!(info.user && callRoom.owner_id === info.user.id);++ info.forceMuted = listenOnly;++ info.forceCameraOff = listenOnly;++ if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }++ send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: this._canModerate(info.user, channelId) });++ send({ type: 'force-muted', forceMuted: info.forceMuted });++ send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });++ const r = this.rooms.get(channelId);++ if (r) {++ const m1 = JSON.stringify({ type: 'peer-force-muted', peerId, forceMuted: info.forceMuted });++ const m2 = JSON.stringify({ type: 'peer-force-camera-off', peerId, forceCameraOff: info.forceCameraOff });++ const m3 = JSON.stringify({ type: 'peer-updated', ...this._buildParticipantInfo(peerId, info) });++ for (const [pid, other] of r) {++ if (other.ws.readyState !== WebSocket.OPEN) continue;++ other.ws.send(m1); other.ws.send(m2);++ if (pid !== peerId) other.ws.send(m3);++ }++ }++ this._notifyChannelsChanged();++ return true;++ }++endCall(channelId, reason = 'ended') {const r = this.rooms.get(channelId); if (!r) return;const entries = [...r.values()];@@@ -516,7 +516,7 @@ class ChatServer {if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }client.roomId = room.id;client.roomSlug = room.slug;-- this.sendTo(ws, { type: 'room_joined', room: room.slug, role: a.role, can: { post: a.post, moderate: a.moderate } });++ this.sendTo(ws, { type: 'room_joined', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });break;}case 'leave_room':@@@ -2432,6 +2432,23 @@ class ChatServer {}}++ /**++ * A room's roles or visibility changed: every socket following it learns what it may do now++ * (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told++ */++ refreshRoomAccess(room) {++ const rooms = require('../rooms/rooms');++ let n = 0;++ for (const [ws, client] of this.clients) {++ if (!room || client.roomId !== room.id) continue;++ const a = rooms.access(room, client.user || null);++ n++;++ if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }++ this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });++ }++ return n;++ }++/** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */removeFromRoom(roomId, userId) {let n = 0;@@@ -59,10 +59,16 @@ function publicRows(rows) {return rows;}--/** Load the cosmetics/tags of every author in these rows (one batched call for the misses). */++/**++ * Load the cosmetics/tags of every author in these rows (one batched call for the misses), waiting at++ * most DECOR_WAIT_MS: they are Live's, and history and sending never wait on a slow or hung Live.++ */++const DECOR_WAIT_MS = 1500;async function loadDecor(rows) {const ids = (rows || []).map((m) => m && m.user_id).filter(Boolean);-- if (ids.length) { try { await ctx.ensureDecor(ids); } catch { /* history still renders */ } }++ if (!ids.length) return;++ let timer;++ try { await Promise.race([ctx.ensureDecor(ids), new Promise((r) => { timer = setTimeout(r, DECOR_WAIT_MS); if (timer.unref) timer.unref(); })]); } catch { /* history still renders */ } finally { clearTimeout(timer); }}/**@@@ -272,7 +278,9 @@ router.post('/send', requireAuth, async (req, res) => {}const chatServer = require('./chat-server');-- try { await ctx.ensureDecor([req.user.id]); } catch { /* non-critical */ }++ // Cosmetics are Live's and only decorate the line: wait for them briefly, never for a slow or++ // hanging Live (openvibe.chat keeps working with Live down).++ await loadDecor([{ user_id: req.user.id }]);// Word filterconst wordFilter = require('./word-filter');@@@ -51,6 +51,8 @@ module.exports = {// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.web: {baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),++ // Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).++ communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),},cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * ICE servers for calls (openvibe.chat's call rooms; the same logic as OpenVibe.Live server/net/turn.js++ * and its GET /api/auth/ice-servers, so both sites hand out the same kind of list).++ *++ * GET /api/chat/ice-servers → { iceServers: [{ urls }, { urls, username, credential }…] }++ *++ * One STUN server, plus the TURN server when TURN_URL is set and can be authenticated. With++ * TURN_AUTH_SECRET (coturn `use-auth-secret` with the same `static-auth-secret`) every list carries a++ * credential that expires (TURN REST API: username `<expiry>:<tag>`, credential base64 HMAC-SHA1 of the++ * username); otherwise the static TURN_USERNAME / TURN_CREDENTIAL pair; a turn: URL with neither is left++ * out (a browser refuses such an entry outright). Nothing here asks another service.++ */++const crypto = require('crypto');++const express = require('express');++++const TTL_SECONDS = 3600;++const STUN = { urls: 'stun:stun.l.google.com:19302' };++++/** turn:host[:port][?query] or turns:…, no credentials in it; '' when unusable. */++function normalizeTurnUrl(raw) {++ const v = String(raw || '').trim();++ if (!v) return '';++ const m = /^(turns?):(?:\/\/)?([^/?#@\s]+)([?][^#\s]*)?$/i.exec(v);++ if (!m) return '';++ return `${m[1].toLowerCase()}:${m[2]}${m[3] || ''}`;++}++++function turnConfig(env = process.env) {++ return {++ url: normalizeTurnUrl(env.TURN_URL),++ secret: String(env.TURN_AUTH_SECRET || '').trim(),++ username: String(env.TURN_USERNAME || '').trim(),++ credential: String(env.TURN_CREDENTIAL || '').trim(),++ };++}++++function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now()) {++ if (cfg.secret) {++ const username = `${Math.floor(now / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;++ return { username, credential: crypto.createHmac('sha1', cfg.secret).update(username).digest('base64'), ephemeral: true };++ }++ if (cfg.username && cfg.credential) return { username: cfg.username, credential: cfg.credential, ephemeral: false };++ return null;++}++++/** The list for a viewer (tag: u<id> or anon). */++function iceServers(tag = 'anon', cfg = turnConfig(), now = Date.now()) {++ const servers = [STUN];++ let ephemeral = false;++ if (cfg.url) {++ const c = turnCredentials(tag, cfg, now);++ if (c) {++ const tcp = cfg.url.includes('?') ? `${cfg.url}&transport=tcp` : `${cfg.url}?transport=tcp`;++ servers.push({ urls: cfg.url, username: c.username, credential: c.credential }, { urls: tcp, username: c.username, credential: c.credential });++ ephemeral = c.ephemeral;++ }++ }++ return { iceServers: servers, ephemeral };++}++++function createIceRoutes() {++ const { optionalAuth } = require('../auth/auth');++ const router = express.Router();++ router.get('/', optionalAuth, (req, res) => {++ const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');++ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });++ });++ return router;++}++++module.exports = { createIceRoutes, iceServers, turnCredentials, normalizeTurnUrl, turnConfig, TTL_SECONDS };@@@ -1,18 +1,47 @@'use strict';/**-- * Chat rooms (roadmap WS-I task 4, first release): rooms anyone can create on openvibe.chat, beside the-- * global room and the stream/channel rooms Live's chat already has.++ * Chat rooms (roadmap WS-I task 4): rooms people create on openvibe.chat, beside the global room and++ * the stream/channel rooms Live's chat already has. Three kinds:*-- * rooms slug (URL id), name, topic, kind (community | system | call), visibility-- * (public: anyone reads, signed-in people join; private: members only), owner, slow mode-- * room_members role: owner | mod | member | blocked; last_read_id for unread counts-- * room_messages the messages (soft-deleted by their author, a room mod or chat staff)++ * community a conversation anyone can start (public or invite-only)++ * call the room a call happens in: its text chat, and a voice/video channel on /ws/call++ * (channel id `room-<slug>`, server/calls/call-server.js) where the room's roles decide who++ * talks and who listens++ * system platform-authored: announcements written by chat staff, read-only for people*-- * Moderation: the owner and mods delete messages and block members; site bans and chat staff-- * (staff.moderation.chat) apply in every room. Every staff/mod action is logged through-- * db.logModerationAction (scope room:<slug>), which also reports it to Network's audit log.-- * Nothing here talks to Live except the ban check (ctx.isUserBanned), so rooms keep working with-- * Live down. chat-server.js delivers messages live to sockets that joined the room (join_room).++ * rooms slug (URL id), name, topic, kind, visibility (public: anyone reads, signed-in++ * people join; private: members only), owner, slow mode, join_role (call rooms:++ * the role people get when they join: speaker, participant or viewer)++ * room_members role (below); last_read_id for unread counts; last_seen_at (last read or post)++ * room_messages the messages (soft-deleted by their author, a room mod or chat staff)++ * room_attachments where a room is attached elsewhere (a Community space): the room's side of the++ * link, made by a person who manages the room (the other service keeps its own)++ *++ * Roles and what they may do (access() is the only place this is decided):++ *++ * kind role read post call: join talk moderate manage++ * community owner yes yes - - yes yes++ * mod yes yes - - yes -++ * member yes yes - - - -++ * viewer yes - - - - - (read-only member)++ * call owner yes yes yes yes yes yes++ * mod yes yes yes yes yes -++ * speaker yes yes yes yes - -++ * participant yes yes yes - - - (listens, writes in the text chat)++ * viewer yes - yes - - - (listens and reads)++ * system owner yes yes - - yes yes (only chat staff create them)++ * mod yes yes - - yes -++ * viewer yes - - - - - (everyone who joins)++ * any blocked - - - - - -++ * no role public room: read (and listen in a call room); private room: nothing, not even that it exists++ * chat staff (staff.moderation.chat): read, moderate and manage every room, post in system rooms, talk in calls++ * a site ban: no posting and no talking, whatever the role++ *++ * Owners and chat staff appoint mods; owners and mods set every other role. Every staff/mod action++ * on someone else is logged through db.logModerationAction (scope room:<slug>), which also reports it to++ * Network's audit log. Nothing here talks to Live except the ban check (ctx.isUserBanned, a cached++ * read), so rooms keep working with Live down. chat-server.js delivers messages live to sockets that++ * joined the room (join_room).*/const db = require('../db/database');const ctx = require('../live-context');@@@ -20,9 +49,27 @@ const wordFilter = require('../chat/word-filter');const permissions = require('../auth/permissions');const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;--const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system']);--const ROLES = ['owner', 'mod', 'member', 'blocked'];--const KINDS = ['community', 'system', 'call'];++const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);++const KINDS = ['community', 'call', 'system'];++const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];++/** The roles each kind has (owner is never assigned: a room has one, its creator). */++const KIND_ROLES = Object.freeze({++ community: ['owner', 'mod', 'member', 'viewer', 'blocked'],++ call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],++ system: ['owner', 'mod', 'viewer', 'blocked'],++});++/** What joining a public room makes you (a call room's owner picks one of JOIN_ROLES_CALL). */++const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer' });++const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];++const POSTERS = Object.freeze({++ community: new Set(['owner', 'mod', 'member']),++ call: new Set(['owner', 'mod', 'speaker', 'participant']),++ system: new Set(['owner', 'mod']),++});++const TALKERS = new Set(['owner', 'mod', 'speaker']);++const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };++/** Services a room can be attached to, and what names the resource there. */++const ATTACH_SERVICES = Object.freeze({ community: { resource: /^[a-z0-9][a-z0-9-]{1,39}$/, noun: 'space' } });const MAX_TEXT = 2000;const MAX_ROOMS_PER_OWNER = 10;const RATE = { max: 6, windowMs: 10_000 };@@@ -35,7 +82,8 @@ const fail = (status, code, message) => { throw new RoomError(status, code, messlet ready = false;function ensureSchema() {if (ready) return;-- db.getDb().exec(`++ const d = db.getDb();++ d.exec(`CREATE TABLE IF NOT EXISTS rooms (id INTEGER PRIMARY KEY AUTOINCREMENT,slug TEXT UNIQUE NOT NULL,@@@ -71,13 +119,29 @@ function ensureSchema() {deleted_by INTEGER);CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);++ CREATE TABLE IF NOT EXISTS room_attachments (++ room_id INTEGER NOT NULL,++ service TEXT NOT NULL,++ resource TEXT NOT NULL,++ title TEXT,++ attached_by INTEGER NOT NULL,++ attached_by_subject TEXT,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (room_id, service, resource)++ );`);++ // Additive columns on tables the first release created (ADD COLUMN is a metadata change: safe live).++ const cols = (t) => new Set(d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name));++ if (!cols('rooms').has('join_role')) d.exec('ALTER TABLE rooms ADD COLUMN join_role TEXT');++ if (!cols('room_members').has('last_seen_at')) d.exec('ALTER TABLE room_members ADD COLUMN last_seen_at DATETIME');ready = true;}const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());++const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');++const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);function slugify(name) {return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');@@@ -86,7 +150,8 @@ function slugify(name) {function publicRoom(r, extra = {}) {if (!r) return null;return {-- id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: r.kind, visibility: r.visibility,++ id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,++ ...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,...extra,@@@ -98,17 +163,26 @@ function bySlug(slug) {return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;}--/** What `user` may do in `room`: { read, post, moderate, role }. */++/**++ * What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.++ * `join` and `talk` are the room's call (call rooms only).++ */function access(room, user) {++ const kind = kindOf(room);const role = user ? roleOf(room.id, user.id) : null;const staff = isStaff(user);const blocked = role === 'blocked';const member = !!role && !blocked;++ const banned = siteBanned(user);const read = !blocked && (room.visibility === 'public' || member || staff);-- return { role, read, post: !!user && member && !siteBanned(user), moderate: !!user && (role === 'owner' || role === 'mod' || staff), manage: !!user && (role === 'owner' || staff) };++ const moderate = !!user && !blocked && (role === 'owner' || role === 'mod' || staff);++ const post = !!user && read && !banned && ((member && POSTERS[kind].has(role)) || (kind === 'system' && staff));++ const join = kind === 'call' && read;++ const talk = join && !!user && !banned && ((member && TALKERS.has(role)) || staff);++ return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };}--function create(user, { name, slug, topic, visibility = 'public', kind = 'community' } = {}) {++function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {ensureSchema();if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');@@@ -117,15 +191,18 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'communconst s = slug ? String(slug).toLowerCase().trim() : slugify(n);if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');-- if (!KINDS.includes(kind) || (kind !== 'community' && !isStaff(user))) fail(422, 'rooms.kind', 'Only chat staff create system and call rooms');++ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call or system room');++ if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');++ if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;const id = db.transaction(() => {-- const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?)', [s, n, t, kind, visibility, user.id, user.subject_id || null]);-- db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [r.lastInsertRowid, user.id]);++ const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',++ [s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);++ db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)", [r.lastInsertRowid, user.id]);return Number(r.lastInsertRowid);});return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });@@@ -136,23 +213,35 @@ function list(user, { limit = 50 } = {}) {ensureSchema();const lim = Math.min(100, Math.max(1, Number(limit) || 50));const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));-- const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id,++ const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unreadFROM room_members m JOIN rooms r ON r.id = m.room_idWHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL-- ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread })) : [];++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })) : [];return { public: pub, mine };}++/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */++function unreadTotal(user) {++ if (!user) return 0;++ ensureSchema();++ return (db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL++ JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id++ WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;++}++++/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */function join(room, user) {if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');const role = roleOf(room.id, user.id);if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');if (role) return role;-- if (room.visibility !== 'public' && !isStaff(user)) fail(403, 'rooms.private', 'This room is invite-only');++ // A private room is not there for someone it has not invited (the same answer as a missing room).++ if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;-- db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id) VALUES (?, ?, 'member', ?)", [room.id, user.id, last]);-- return 'member';++ const r = joinRoleOf(room);++ db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', [room.id, user.id, r, last]);++ return r;}function leave(room, user) {@@@ -212,7 +301,11 @@ function post(room, user, text, { now = Date.now() } = {}) {const a = access(room, user);if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');-- if (!a.post) fail(403, room.visibility === 'public' ? 'rooms.join_first' : 'rooms.private', room.visibility === 'public' ? 'Join the room to post' : 'This room is invite-only');++ if (!a.post) {++ if (!a.read) fail(404, 'rooms.not_found', 'No such room');++ if (!a.role) fail(403, 'rooms.join_first', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Join the room to post');++ fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');++ }const body = String(text || '').replace(/\r\n/g, '\n').trim();if (!body) fail(422, 'rooms.empty', 'Write something first');if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);@@@ -227,7 +320,7 @@ function post(room, user, text, { now = Date.now() } = {}) {const id = db.transaction(() => {const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);-- db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);++ db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);announceMessage(room, user, Number(r.lastInsertRowid), message, now);return Number(r.lastInsertRowid);});@@@ -252,7 +345,7 @@ function markRead(room, user, lastId) {if (!user) return;const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;const to = Math.min(top, Number(lastId) || top);-- db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);++ db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);}function deleteMessage(room, user, messageId) {@@@ -269,36 +362,50 @@ function deleteMessage(room, user, messageId) {return m.id;}--/** Owner/mod actions on a member: invite (by user id), set a role (mod/member), block or unblock. */++/**++ * Owner/mod actions on a person: add them (a role, by user id), change their role, block, unblock or++ * remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or++ * chat staff's) to appoint and remove; everything else is any moderator's.++ */function setRole(room, actor, targetUserId, role) {++ const kind = kindOf(room);if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');++ if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');++ if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);const a = access(room, actor);++ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');const current = roleOf(room.id, targetUserId);if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');-- if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');-- if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');const target = ctx.getUserById(targetUserId);if (!target) fail(404, 'rooms.no_user', 'No such person');if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);-- if (role === 'blocked' || current === 'blocked' || role === 'mod' || current === 'mod') {-- db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: role === 'blocked' ? 'room_block' : current === 'blocked' ? 'room_unblock' : role === 'mod' ? 'room_mod_add' : 'room_mod_remove', details: {} });-- }++ const action = role === 'blocked' && current !== 'blocked' ? 'room_block'++ : current === 'blocked' && role !== 'blocked' ? 'room_unblock'++ : role === 'mod' && current !== 'mod' ? 'room_mod_add'++ : current === 'mod' && role !== 'mod' ? 'room_mod_remove'++ : role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'++ : current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;++ if (action) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });return role;}--function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {++function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');const next = { ...room };if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }++ if (join_role != null && join_role !== '') {++ if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');++ next.join_role = join_role;++ }db.transaction(() => {-- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);++ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);// Public → private: everything the room published stops being replayable on the realtime plane.if (room.visibility === 'public' && next.visibility === 'private') {announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));@@@ -307,11 +414,61 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));}--function members(room) {-- return db.all(`SELECT m.user_id, m.role, u.username, u.display_name FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id-- WHERE m.room_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'mod' THEN 1 WHEN 'member' THEN 2 ELSE 3 END, u.username COLLATE NOCASE LIMIT 500`, [room.id]);++/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */++function members(room, { seen = false } = {}) {++ const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;++ return db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id++ WHERE m.room_id = ? ORDER BY ${order}, u.username COLLATE NOCASE LIMIT 500`, [room.id]);++}++++// ── Attachments: the room's side of a link from another service (a Community space) ──++// The other service keeps the link (Community stores the room on the space) and checks its own side++// (the space's owner or staff); Chat checks that the person attaching manages this room. So one person++// holding both ends makes the link, with their own Network token: no service grant is involved.++++function attachmentShape(r) {++ return { service: r.service, resource: r.resource, title: r.title || null, attached_by: r.attached_by, created_at: r.created_at };++}++++/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */++function attach(room, user, { service, resource, title } = {}) {++ ensureSchema();++ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');++ if (!access(room, user).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');++ const svc = ATTACH_SERVICES[service];++ if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);++ const res = String(resource || '').toLowerCase();++ if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);++ const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;++ const existing = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);++ if (existing) {++ if (t && t !== existing.title) db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);++ return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };++ }++ db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);++ return { attachment: attachmentShape(db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };++}++++/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */++function detach(room, user, service, resource) {++ ensureSchema();++ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');++ const row = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);++ if (!row) return { removed: false };++ if (!access(room, user).manage && row.attached_by !== user.id) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');++ db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);++ return { removed: true };++}++++function attachments(room) {++ ensureSchema();++ return db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id]).map(attachmentShape);}function _reset() { ready = false; _rate.clear(); }--module.exports = { ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById, RoomError, SLUG_RE, _reset };++module.exports = {++ ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,++ attach, detach, attachments, joinRoleOf, unreadTotal,++ RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,++};@@@ -4,15 +4,23 @@* chat-server.js carries the same rooms live (join_room / room_message).** GET / public rooms by activity + the caller's rooms (with unread counts)-- * POST / create { name, slug?, topic?, visibility? }-- * GET /:slug the room and what the caller may do (404 when they may not read it)-- * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds? }++ * POST / create { name, slug?, topic?, visibility?, kind?: community | call | system (staff),++ * join_role?: speaker | participant | viewer (call rooms) }++ * GET /:slug the room, what the caller may do (can: read/post/join/talk/moderate/manage)++ * and, for a call room, its call (404 when they may not read it)++ * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds?, join_role? }* GET /:slug/messages ?before=<id> | ?after=<id>, &limit* POST /:slug/messages { message }* DELETE /:slug/messages/:id the author, a room moderator or chat staff* POST /:slug/join | /leave | /read { last_id? }-- * GET /:slug/members-- * POST /:slug/members owner/mods: { username, role: member | mod | blocked | none }++ * GET /:slug/members (moderators also get last_seen_at)++ * POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none++ * GET /:slug/attachments managers: where the room is attached (a Community space)++ * POST /:slug/attachments managers, with their own Network token: { service: 'community', resource: <space slug>, title? }++ * DELETE /:slug/attachments/:service/:resource managers or whoever attached it (both idempotent)++ *++ * Role and room changes reach a running call at once (call-server.js applyRoomAccess): a new speaker++ * may talk, a demoted one is muted, a blocked person is dropped.*/const express = require('express');const { requireAuth, optionalAuth } = require('../auth/auth');@@@ -27,6 +35,25 @@ function send(res, err) {return res.status(500).json({ error: 'Something went wrong' });}const chatServer = () => require('../chat/chat-server');++/**++ * After a role or room change: sockets following the room learn what they may do now, and a running++ * call in it follows the room's roles (no-op when calls are off or nobody is in).++ */++function applyRoomChange(room) {++ const fresh = rooms.bySlug(room.slug) || room;++ try { chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }++ try { require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }++}++/** The room's call as the room page shows it: on or off, and who is in. */++function callOf(room) {++ if (room.kind !== 'call') return undefined;++ const enabled = !!require('../config').calls.enabled;++ let people = [];++ try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }++ return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };++}++/** People only: attaching speaks for the person (API tokens and services cannot). */++const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */function readable(req, res) {@@@ -46,12 +73,16 @@ router.post('/', requireAuth, (req, res) => {router.get('/:slug', optionalAuth, (req, res) => {const room = readable(req, res); if (!room) return;const a = rooms.access(room, req.user || null);-- res.set('Cache-Control', 'private, no-store').json({ room: rooms.publicRoom(room, { role: a.role }), can: { post: a.post, moderate: a.moderate, manage: a.manage } });++ res.set('Cache-Control', 'private, no-store').json({++ room: rooms.publicRoom(room, { role: a.role }),++ can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },++ ...(room.kind === 'call' ? { call: callOf(room) } : {}),++ });});router.patch('/:slug', requireAuth, (req, res) => {const room = readable(req, res); if (!room) return;-- try { res.json({ room: rooms.update(room, req.user, req.body || {}) }); } catch (err) { send(res, err); }++ try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }});router.get('/:slug/messages', optionalAuth, (req, res) => {@@@ -83,12 +114,17 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {router.post('/:slug/join', requireAuth, (req, res) => {const room = rooms.bySlug(req.params.slug);if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });-- try { res.json({ ok: true, role: rooms.join(room, req.user) }); } catch (err) { send(res, err); }++ try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }});router.post('/:slug/leave', requireAuth, (req, res) => {const room = readable(req, res); if (!room) return;-- try { rooms.leave(room, req.user); chatServer().removeFromRoom(room.id, req.user.id); res.json({ ok: true }); } catch (err) { send(res, err); }++ try {++ rooms.leave(room, req.user);++ chatServer().removeFromRoom(room.id, req.user.id);++ applyRoomChange(room);++ res.json({ ok: true });++ } catch (err) { send(res, err); }});router.post('/:slug/read', requireAuth, (req, res) => {@@@ -98,7 +134,8 @@ router.post('/:slug/read', requireAuth, (req, res) => {router.get('/:slug/members', optionalAuth, (req, res) => {const room = readable(req, res); if (!room) return;-- res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room).filter((m) => m.role !== 'blocked' || rooms.access(room, req.user || null).moderate) });++ const moderate = rooms.access(room, req.user || null).moderate;++ res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });});router.post('/:slug/members', requireAuth, async (req, res) => {@@@ -109,9 +146,32 @@ router.post('/:slug/members', requireAuth, async (req, res) => {if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));-- if (role === 'blocked' || role === 'none' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);++ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);++ applyRoomChange(room);res.json({ ok: true, user_id: target.id, username: target.username, role });} catch (err) { send(res, err); }});++router.get('/:slug/attachments', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ if (!rooms.access(room, req.user).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });++ res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });++});++++router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try {++ const b = req.body || {};++ const out = rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });++ res.status(out.created ? 201 : 200).json({ ...out, room: rooms.publicRoom(room) });++ } catch (err) { send(res, err); }++});++++router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }++});++module.exports = router;++module.exports.callOf = callOf;++module.exports.applyRoomChange = applyRoomChange;@@@ -35,11 +35,17 @@ function asset(rel) {return `/web/${rel}?v=${hashes.get(rel)}`;}++/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */++function links(o) {++ const counts = o.counts || {};++ return LINKS.map((l) => ({ key: l.key, href: l.href, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));++}++function navConfig(o, config) {return {service: 'chat',apiBase: NETWORK_URL,-- links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),++ links: links(o).map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -50,7 +56,8 @@ function navConfig(o, config) {/*** o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),-- * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)++ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread++ * rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)*/function renderPage(o) {const { config } = o;@@@ -79,11 +86,12 @@ ${appIcon.headTags({ site: 'network' })}<script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script>${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}++${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}</head><body class="${cls}"${scale}><a class="oc-skip" href="#main">Skip to content</a><div id="navbar-mount"></div>--${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}++${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}<main id="main" class="oc-main"><noscript><p class="oc-account">${who}</p></noscript>${o.body || ''}@@@ -6,8 +6,12 @@* GET / global chat: the last messages server-rendered (readable without* JavaScript); public/web/chat.js adds the live feed over /ws/chat* POST /send the composer without JavaScript (the same handler as POST /api/chat/send)-- * GET /messages your conversations; POST /messages/new starts one by username-- * GET /messages/:id one conversation (participants only); POST /messages/:id replies++ * GET /messages your inbox: conversations with unread counts, people you blocked;++ * POST /messages/new starts one by username; POST /messages/unblock/:userId++ * GET /messages/:id one conversation (participants only); POST /messages/:id replies;++ * POST /messages/:id/block blocks the other person of a 1:1 conversation++ * GET /rooms, /r/:slug rooms: community, call (a voice/video call in the page, public/web/call.js)++ * and system (staff announcements); /r/:slug/settings for moderators* GET /settings your portable chat settings (Network module chat.preferences); POST saves* GET /updates what shipped on openvibe.chat (the shared changelog)* GET /robots.txt, /sitemap.xml@@@ -16,6 +20,11 @@* Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,* bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this* site (Origin check; the session cookie is SameSite=Lax).++ *++ * Working with Live down: nothing here waits on OpenVibe.Live. People are signed in from their Network++ * token and the ctx_users projection (auth/network-session.js), and every page reads Chat's own++ * tables; the only openvibe.live URLs are links to people's profiles (navigation, never fetched).++ * test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.*/const path = require('path');const express = require('express');@@@ -110,10 +119,15 @@ function createWebRoutes({ config }) {if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');next();};++ const countsOf = (actor) => {++ if (actor.kind !== 'user') return {};++ const n = (fn) => { try { return Number(fn()) || 0; } catch { return 0; } };++ return { messages: n(() => dm.getTotalUnread(actor.user.id)), rooms: n(() => require('../rooms/rooms').unreadTotal(actor.user)) };++ };const page = async (req, res, status, o) => {const actor = o.actor || await viewerOf(req);res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')-- .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: countsOf(actor), ...o }));};const needUser = async (req, res) => {const actor = await viewerOf(req);@@@ -179,14 +193,29 @@ ${composer}return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;});const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';++ const blocked = dm.getBlockedUsers(me.id);++ const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';++ const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);await page(req, res, 200, {-- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',-- body: `<h1>Messages</h1>${error}++ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true,++ page: { view: 'inbox', unread, me: me.id },++ body: `<h1>Messages</h1>${error}${done}++<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p><form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>--<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,++<ul class="oc-convs" id="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>++${blockedList}`,});});++ router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const id = parseInt(req.params.userId, 10);++ if (Number.isInteger(id) && id > 0) dm.unblockUser(actor.user.id, id);++ const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';++ res.redirect(303, back);++ });++router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {const actor = await needUser(req, res); if (!actor) return;const name = String((req.body && req.body.username) || '').trim();@@@ -212,15 +241,36 @@ ${composer}const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);try { dm.markRead(id, me.id); } catch { /* best effort */ }const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ // A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.++ const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;++ const iBlocked = other ? dm.hasBlocked(me.id, other.id) : false;++ const closed = other ? dm.isBlockedEither(me.id, other.id) : false;++ const otherName = other ? esc(other.display_name || other.username) : '';++ let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;++ if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;++ else if (closed) composer = notice('error', 'You cannot message this person.');++ const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';await page(req, res, 200, {actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>--<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,++${composer}${tools}`,});});++ router.post('/messages/:id/block', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const me = actor.user;++ const id = parseInt(req.params.id, 10);++ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');++ const conv = dm.getConversation(id);++ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);++ if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);++ dm.blockUser(me.id, others[0].id);++ res.redirect(303, `/messages/${id}`);++ });++router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {if (!(await needUser(req, res))) return;const id = parseInt(req.params.id, 10);@@@ -304,34 +354,46 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}// ── Rooms (server/rooms/) ──const rooms = require('../rooms/rooms');++ const roomRoutes = require('../rooms/routes');const roomsOrError = (fn) => async (req, res, next) => {try { return await fn(req, res, next); } catch (err) {if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);return next(err);}};-- const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;++ const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };++ const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };++ const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');++ const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;const roomMessage = (m, can, slug, me) => {const t = hhmm(m.created_at);const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? esc(m.profile_color) : 'inherit'}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;};++ const applyRoomChange = (room) => roomRoutes.applyRoomChange(room);router.get('/rooms', async (req, res) => {const actor = await viewerOf(req);const me = actor.kind === 'user' ? actor.user : null;const { public: pub, mine } = rooms.list(me);const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const staff = !!me && permissions.can(me, 'staff.moderation.chat');const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new"><h2>Start a room</h2><label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label><label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>++<fieldset class="oc-fieldset"><legend>Kind</legend>++<label class="oc-check"><input type="radio" name="kind" value="community" checked> Chat room: people write to each other</label>++<label class="oc-check"><input type="radio" name="kind" value="call"> Call room: a voice call with a text chat beside it</label>++${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system"> Announcements: only OpenVibe staff post (staff)</label>' : ''}++</fieldset>++<label>In a call room, people who join <select name="join_role"><option value="participant">listen and write (participants)</option><option value="speaker">can talk (speakers)</option><option value="viewer">listen and read (viewers)</option></select></label><label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label><p><button type="submit">Create room</button></p></form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;await page(req, res, 200, {actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',-- description: 'Chat rooms on OpenVibe: start one about anything, invite people, or join a public room.',++ description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',body: `<h1>Rooms</h1>${error}${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>@@@ -342,7 +404,8 @@ ${create}`,router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const b = req.body || {};-- const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public' });++ const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';++ const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });res.redirect(303, `/r/${room.slug}`);}));@@@ -356,28 +419,55 @@ ${create}`,return { actor, me, room, can };};++ /** A call room's call: who is in, and the button public/web/call.js turns into the call. */++ function callPanel(room, can, me) {++ const call = roomRoutes.callOf(room) || { enabled: false, participants: 0, people: [] };++ if (!call.enabled) return `<section class="oc-call" aria-labelledby="oc-call-h"><h2 id="oc-call-h">Call</h2><p class="oc-muted">Calls are not available right now. The room's chat works as usual.</p></section>`;++ const people = call.people.length++ ? `<ul class="oc-call-people" id="oc-call-people">${call.people.map((p) => `<li>${esc(p.display_name || p.username || 'Guest')}${p.listening ? ' <span class="oc-muted">(listening)</span>' : ''}</li>`).join('')}</ul>`++ : '<ul class="oc-call-people" id="oc-call-people"><li class="oc-muted">Nobody is in the call yet.</li></ul>';++ const how = can.talk ? 'You can talk in this call.' : me ? 'You join listening. The owner or a moderator can make you a speaker.' : 'Anyone can listen. Sign in to be made a speaker.';++ return `<section class="oc-call" aria-labelledby="oc-call-h" id="oc-call">++<h2 id="oc-call-h">Call <span class="oc-muted oc-call-count" id="oc-call-count">${call.participants ? `· ${call.participants} in` : ''}</span></h2>++<p class="oc-muted" id="oc-call-how">${how}</p>++${people}++<p class="oc-call-controls"><button type="button" id="oc-call-join" hidden>${can.talk ? 'Join the call' : 'Listen in'}</button> <button type="button" id="oc-call-mute" hidden aria-pressed="false">Mute</button> <button type="button" id="oc-call-leave" hidden>Leave the call</button></p>++<p class="oc-muted oc-call-status" id="oc-call-status" role="status"></p>++<noscript><p class="oc-muted">The call needs JavaScript; the room's chat works without it.</p></noscript>++</section>`;++ }++router.get('/r/:slug', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, me, room, can } = x;const msgs = rooms.history(room, { limit: 60 });if (me && can.role) rooms.markRead(room, me);const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';let composer;if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;-- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;-- else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">Join this room</button></form>`;++ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>`;++ else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;++ else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;++ else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;else composer = notice('error', 'You cannot post here right now.');const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';++ const YOU_ARE = { owner: 'the owner', mod: 'a moderator', speaker: 'a speaker', participant: 'a participant', member: 'a member', viewer: 'a viewer' };++ const yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';++ const isCall = room.kind === 'call';++ const callReady = isCall && !!require('../config').calls.enabled;await page(req, res, 200, {-- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true,-- description: room.topic || `${room.name}, a chat room on OpenVibe.`,-- page: { view: 'room', room: room.slug, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate },++ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,++ description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,++ page: { view: 'room', room: room.slug, kind: room.kind, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,++ ...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },body: `<p><a href="/rooms">← Rooms</a></p>--<h1>${esc(room.name)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>--<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>++<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>++<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>${error}--<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>++${isCall ? callPanel(room, can, me) : ''}++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>${composer}${tools}`,});});@@@ -385,7 +475,7 @@ ${composer}${tools}`,router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = rooms.bySlug(req.params.slug);-- if (!room) return res.redirect(303, '/rooms');++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;const message = rooms.post(room, actor.user, req.body && req.body.message);require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });@@@ -395,9 +485,10 @@ ${composer}${tools}`,router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = rooms.bySlug(req.params.slug);-- if (!room) return res.redirect(303, '/rooms');++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;rooms.join(room, actor.user);++ applyRoomChange(room);res.redirect(303, `/r/${room.slug}`);}));@@@ -408,13 +499,14 @@ ${composer}${tools}`,req.roomBack = `/r/${room.slug}`;rooms.leave(room, actor.user);require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);++ applyRoomChange(room);res.redirect(303, '/rooms');}));router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = rooms.bySlug(req.params.slug);-- if (!room) return res.redirect(303, '/rooms');++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });@@@ -427,8 +519,18 @@ ${composer}${tools}`,if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);const saved = req.query.saved ? notice('ok', 'Saved.') : '';const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';-- const members = rooms.members(room);-- const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>' : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${[...(can.manage ? ['mod'] : []), 'member', 'blocked', 'none'].map((r) => `<option value="${r}"${r === m.role ? ' selected' : ''}>${r === 'none' ? 'remove' : r}</option>`).join('')}</select> <button type="submit">Set</button></form>`;++ const members = rooms.members(room, { seen: true });++ const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;++ const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];++ const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;++ const seen = (m) => { const t = hhmm(m.last_seen_at); return t.iso ? ` <span class="oc-muted">· seen <time datetime="${t.iso}">${t.iso.slice(0, 10)}</time></span>` : ''; };++ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>'++ : (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'++ : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;++ const addRole = rooms.joinRoleOf(room);++ const attachments = can.manage ? rooms.attachments(room) : [];++ const communityUrl = config.web.communityUrl;++ const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';await page(req, res, 200, {actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}@@@ -436,35 +538,55 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label><label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label><label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>++${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label><p><button type="submit">Save</button></p></form>` : ''}<h2>Members</h2>--<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="member"> <button type="submit">Add</button></form>--<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span> ${roleForm(m)}</li>`).join('')}</ul>`,++<p class="oc-muted">${esc(roleHelp(room.kind))}</p>++<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>++<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span>${seen(m)} ${roleForm(m)}</li>`).join('')}</ul>++${attachList}`,});});++ function roleHelp(kind) {++ if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';++ if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';++ return 'Members write; viewers only read; moderators delete messages and manage members.';++ }++router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = rooms.bySlug(req.params.slug);-- if (!room) return res.redirect(303, '/rooms');++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}/settings`;const b = req.body || {};-- rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds) });++ rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });++ applyRoomChange(room);res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = rooms.bySlug(req.params.slug);-- if (!room) return res.redirect(303, '/rooms');++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}/settings`;const name = String((req.body && req.body.username) || '').trim();let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);-- const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || 'member'));-- if (role === 'blocked' || role === 'none') require('../chat/chat-server').removeFromRoom(room.id, target.id);++ const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));++ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);++ applyRoomChange(room);++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);++ }));++++ router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/settings`;++ rooms.detach(room, actor.user, req.params.service, req.params.resource);res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * openvibe.chat's DM inbox (roadmap WS-I task 8, server/web/pages.js): the inbox lists your conversations++ * with unread counts (and the navigation counts unread messages and room messages); a conversation is++ * for its participants only, and a guessed id looks exactly like one that does not exist; blocking the++ * other person of a 1:1 conversation closes it both ways (neither can send, nor start a new one, and the++ * user search hides them), the inbox lists who you blocked, and unblocking opens it again; groups have++ * no one to block.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('dm inbox');++let h, alice, bob, carol, conv;++const SITE = 'https://openvibe.chat';++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });++const req = async (method, path, { headers = {}, raw } = {}) => {++ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });++ return { status: r.status, headers: r.headers, text: await r.text() };++};++const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });++const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });++const navLabel = (page, key) => {++ const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);++ return cfg.navbar.links.find((l) => l.href === key).label;++};++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ alice = h.addUser('alice', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ carol = h.addUser('carol', { subject: ids.newId('user') });++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++});++++t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {++ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));++ conv = Number(/^\/messages\/(\d+)$/.exec(r.headers.get('location'))[1]);++ await req('POST', `/messages/${conv}`, form(alice, { message: 'hi bob' }));++ await req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));++ let inbox = await req('GET', '/messages', { headers: cookie(bob) });++ assert.strictEqual(inbox.status, 200);++ assert.match(inbox.text, /<span class="oc-badge">2 new<\/span>/);++ assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);++ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');++ assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');++ const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });++ assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');++ assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');++ assert.ok(!(await dmApi('GET', '/conversations', carol)).body.conversations.some((c) => c.id === conv));++++ // Opening it reads it.++ assert.strictEqual((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).status, 200);++ inbox = await req('GET', '/messages', { headers: cookie(bob) });++ assert.ok(!/\d+ new<\/span>/.test(inbox.text));++ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');++ assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);++++ // Rooms count too.++ await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });++ await h.http('POST', '/api/chat/rooms/book-club/join', { token: bob.token, body: {} });++ for (const m of ['chapter one', 'chapter two', 'chapter three']) await h.http('POST', '/api/chat/rooms/book-club/messages', { token: alice.token, body: { message: m } });++ assert.strictEqual(navLabel((await req('GET', '/', { headers: cookie(bob) })).text, '/rooms'), 'Rooms (3)');++});++++t('participants only: a guessed conversation looks like no conversation', async () => {++ const theirs = await req('GET', `/messages/${conv}`, { headers: cookie(carol) });++ const none = await req('GET', '/messages/999999', { headers: cookie(carol) });++ assert.deepStrictEqual([theirs.status, none.status], [404, 404]);++ const h1 = (x) => (x.text.match(/<h1>(.*?)<\/h1>/) || [])[1];++ assert.strictEqual(h1(theirs), h1(none), 'the same page either way');++ assert.ok(!theirs.text.includes('hi bob'));++ assert.strictEqual((await dmApi('GET', `/conversations/${conv}/messages`, carol)).status, 403);++ assert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);++ const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));++ assert.match(r.headers.get('location'), /error=/);++ assert.ok(!h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv]).some((m) => m.message === 'let me in'));++ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');++});++++t('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {++ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');++ let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });++ assert.match(page.text, /Block Alice<\/button>/);++ let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));++ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);++++ page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });++ assert.match(page.text, /You blocked Alice/);++ assert.match(page.text, /Unblock Alice<\/button>/);++ assert.ok(!page.text.includes('id="oc-compose"'), 'no composer');++ page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });++ assert.match(page.text, /You cannot message this person\./);++ assert.ok(!page.text.includes('id="oc-compose"'));++ assert.ok(!page.text.includes('blocked'), 'the other side is not told who blocked whom');++++ r = await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'still there?' });++ assert.strictEqual(r.status, 403, 'blocked people cannot send');++ r = await req('POST', `/messages/${conv}`, form(alice, { message: 'still there?' }));++ assert.match(r.headers.get('location'), /error=/);++ r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));++ assert.match(r.headers.get('location'), /^\/messages\?error=/, 'nor start a new conversation');++ assert.strictEqual((await dmApi('POST', '/conversations', bob, { user_ids: [alice.id] })).status, 403, 'the blocker cannot either');++ assert.ok(!(await dmApi('GET', '/users/search?q=bo', alice)).body.users.some((u) => u.id === bob.id), 'the user search hides them');++++ const inbox = await req('GET', '/messages', { headers: cookie(bob) });++ assert.match(inbox.text, /People you blocked \(1\)/);++ assert.ok(inbox.text.includes(`action="/messages/unblock/${alice.id}"`));++ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: `/messages/${conv}` }));++ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);++ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: 'https://evil.example/' }));++ assert.strictEqual(r.headers.get('location'), '/messages?unblocked=1', 'back goes to a conversation or the inbox, nowhere else');++ assert.strictEqual((await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'back again' })).status, 200);++ assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /back again/);++});++++t('groups have no one to block', async () => {++ const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });++ assert.strictEqual(r.status, 200, r.text);++ const gid = r.body.conversation.id;++ const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });++ assert.ok(!/Block /.test(page.text));++ assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id]).n, 0);++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -130,7 +130,8 @@ async function boot({ env = {} } = {}) {effects: [], // { name, body }mirror: [], // changesmirrorStatus: 200,-- down: false,++ down: false, // Live refuses: every connection is dropped at once++ hang: false, // Live hangs: connections are accepted and never answerednextAnon: 1,mediaState: { queue: [], now_playing: null },seenTokens: [],@@@ -156,6 +157,7 @@ async function boot({ env = {} } = {}) {live.requests.push(p);const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); };if (live.down) { req.socket.destroy(); return; }++ if (live.hang) { live.hung = (live.hung || 0) + 1; return; }if (p.startsWith('/internal/chat-context/')) {const bad = verify(req, 'live.chat_context.read');if (bad) return send(401, { error: bad });@@@ -430,7 +432,7 @@ async function boot({ env = {} } = {}) {try { h.chatServer.close(); } catch { /* */ }try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }await new Promise((r) => h.server.close(() => r()));-- network.close(); liveServer.close();++ network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();try { h.db.close(); } catch { /* */ }fs.rmSync(tmp, { recursive: true, force: true });};nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * openvibe.chat with OpenVibe.Live down (roadmap WS-I task 8): every page of the site renders and the core++ * APIs work while Live refuses connections, and again while Live accepts them and never answers (a hung++ * Live must not hold a page past the budget). People sign in with their Network token, resolved from++ * the ctx_users projection, so nothing asks Live. Pages load nothing from openvibe.live (profile links++ * are the only Live URLs, and they are links).++ */++const assert = require('assert');++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { boot, suite, b64url } = require('./helpers');++++const t = suite('live down');++let h, alice, bob, carol, aliceJwt, bobJwt, carolJwt, convId;++const SITE = 'https://openvibe.chat';++const now = () => Math.floor(Date.now() / 1000);++const jwt = (u) => {++ const head = `${b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64url(JSON.stringify({ iss: h.ISS, sub: String(u.id + 7000), subject_id: u.subject_id, username: u.username, role: u.role, iat: now() - 5, exp: now() + 3600 }))}`;++ return `${head}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(head), h.keys.privateKey))}`;++};++const req = async (method, path, { token, headers = {}, raw, json } = {}) => {++ const started = Date.now();++ const r = await fetch(`${h.base}${path}`, {++ method, redirect: 'manual',++ headers: { ...(token ? { cookie: `ov_token=${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json', authorization: `Bearer ${token}` } : {}), ...headers },++ body: json !== undefined ? JSON.stringify(json) : raw,++ });++ const text = await r.text();++ let body = null; try { body = JSON.parse(text); } catch { /* html */ }++ return { status: r.status, headers: r.headers, text, body, ms: Date.now() - started };++};++const form = (token, fields) => ({ token, headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: new URLSearchParams(fields).toString() });++const html = { accept: 'text/html' };++++/** No page may load anything from Live: scripts, styles, images, frames, form targets, fetches. */++function assertNoLiveResources(page, path) {++ for (const re of [/<(script|img|iframe|audio|video|source)[^>]+src="https?:\/\/(www\.)?openvibe\.live/i, /<link[^>]+href="https?:\/\/(www\.)?openvibe\.live/i, /<form[^>]+action="https?:\/\/(www\.)?openvibe\.live/i]) {++ assert.ok(!re.test(page), `${path} loads something from openvibe.live (${re})`);++ }++}++++t('boot, and some of everything while Live is up', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ alice = h.addUser('alice', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ carol = h.addUser('carol', { subject: ids.newId('user') });++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++ [aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);++ let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });++ assert.strictEqual(r.status, 200, r.text);++ convId = r.body.conversation.id;++ await req('POST', `/api/dm/conversations/${convId}/messages`, { token: aliceJwt, json: { message: 'before the outage' } });++ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Night Shift', topic: 'while Live sleeps' } })).status, 201);++ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Quiet Corner', visibility: 'private' } })).status, 201);++ assert.strictEqual((await req('POST', '/api/chat/send', { token: aliceJwt, json: { message: 'global before the outage' } })).status, 200);++});++++async function everyPageAndApi(label, budgetMs) {++ const pages = [++ ['/', null], ['/', aliceJwt], ['/rooms', null], ['/rooms', aliceJwt], ['/r/night-shift', null], ['/r/night-shift', bobJwt],++ ['/r/quiet-corner', aliceJwt], ['/r/night-shift/settings', aliceJwt], ['/messages', aliceJwt], [`/messages/${convId}`, bobJwt],++ ['/settings', aliceJwt], ['/updates', null], ['/robots.txt', null], ['/sitemap.xml', null], ['/auth/me', aliceJwt],++ ];++ for (const [path, token] of pages) {++ const r = await req('GET', path, { token, headers: html });++ assert.strictEqual(r.status, 200, `${label}: GET ${path} → ${r.status}`);++ assert.ok(r.ms < budgetMs, `${label}: GET ${path} took ${r.ms} ms`);++ assertNoLiveResources(r.text, path);++ }++ assert.match((await req('GET', '/', { token: aliceJwt })).text, /id="oc-compose"/, `${label}: signed in from the Network token alone`);++ assert.match((await req('GET', `/messages/${convId}`, { token: bobJwt })).text, /before the outage/);++ assert.strictEqual((await req('GET', '/r/quiet-corner', { token: bobJwt })).status, 404, `${label}: private rooms stay private`);++ assert.strictEqual((await req('GET', `/messages/${convId}`, { token: carolJwt })).status, 404, `${label}: so do conversations`);++ assert.strictEqual((await req('GET', '/no-such-page', { headers: html })).status, 404);++++ // The forms, without JavaScript.++ const posts = [++ ['/send', aliceJwt, { message: `global during ${label}` }, /^\/#oc-compose$/],++ ['/r/night-shift', aliceJwt, { message: `room during ${label}` }, /^\/r\/night-shift#oc-compose$/],++ [`/messages/${convId}`, bobJwt, { message: `dm during ${label}` }, new RegExp(`^/messages/${convId}#oc-compose$`)],++ ['/messages/new', carolJwt, { username: 'alice' }, /^\/messages\/\d+$/],++ ];++ for (const [path, token, fields, want] of posts) {++ const r = await req('POST', path, form(token, fields));++ assert.strictEqual(r.status, 303, `${label}: POST ${path} → ${r.status}`);++ assert.match(r.headers.get('location'), want, `${label}: POST ${path} → ${r.headers.get('location')}`);++ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);++ }++ const room = (await req('GET', '/r/night-shift')).text;++ assert.ok(room.includes(`room during ${label}`));++ assert.ok((await req('GET', '/')).text.includes(`global during ${label}`));++++ // The APIs the site's script and apps use.++ const apis = [++ ['GET', '/api/chat/rooms', aliceJwt], ['GET', '/api/chat/rooms/night-shift/messages', null], ['GET', '/api/chat/global/history?limit=20', null],++ ['GET', '/api/dm/conversations', aliceJwt], ['GET', `/api/dm/conversations/${convId}/messages`, aliceJwt], ['GET', '/api/dm/unread', bobJwt],++ ['GET', '/api/chat/ice-servers', null],++ ];++ for (const [method, path, token] of apis) {++ const r = await req(method, path, { headers: token ? { authorization: `Bearer ${token}` } : {} });++ assert.strictEqual(r.status, 200, `${label}: ${method} ${path} → ${r.status} ${r.text.slice(0, 120)}`);++ assert.ok(r.ms < budgetMs, `${label}: ${method} ${path} took ${r.ms} ms`);++ }++ for (const [path, token, json] of [++ ['/api/chat/rooms/night-shift/messages', bobJwt, null],++ [`/api/dm/conversations/${convId}/messages`, aliceJwt, { message: `api dm during ${label}` }],++ ['/api/chat/send', bobJwt, { message: `api global during ${label}` }],++ ]) {++ if (!json) await req('POST', '/api/chat/rooms/night-shift/join', { token, json: {} });++ const r = await req('POST', path, { token, json: json || { message: `api room during ${label}` } });++ assert.ok(r.status === 200 || r.status === 201, `${label}: POST ${path} → ${r.status} ${r.text.slice(0, 160)}`);++ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);++ }++ const hist = await req('GET', '/api/chat/global/history?limit=50');++ assert.ok(hist.body.messages.some((m) => m.message === `api global during ${label}`));++++ // The live feed: a signed-in socket (the browser's cookie, as on openvibe.chat) joins global chat and a++ // room and posts there. Joining warms Live's caches for at most live-context's WARM_TIMEOUT_MS (2.5 s)++ // per step, so a hung Live delays the feed by seconds; messages sent meanwhile wait, in order.++ const socketBudget = label === 'hung' ? 9000 : budgetMs;++ const ws = await h.ws({ origin: 'https://openvibe.live', bearer: aliceJwt });++ ws.sendJson({ type: 'join' });++ const auth = await ws.next((m) => m.type === 'auth', socketBudget);++ assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'alice'], `${label}: the socket signs in from the Network token`);++ ws.sendJson({ type: 'join_room', room: 'night-shift' });++ await ws.next((m) => m.type === 'room_joined', socketBudget);++ ws.sendJson({ type: 'room_message', message: `socket during ${label}` });++ await ws.next((m) => m.type === 'room_message' && m.message.message === `socket during ${label}`, socketBudget);++ ws.close();++}++++t('Live refuses connections: every page renders, the forms and the core APIs work', async () => {++ h.live.down = true;++ const before = h.live.requests.length;++ await everyPageAndApi('refused', 2500);++ h.live.down = false;++ assert.ok(h.live.requests.length >= before, 'Live was down the whole time');++});++++t('Live hangs (accepts, never answers): nothing waits on it past the budget', async () => {++ h.live.hang = true;++ await everyPageAndApi('hung', 3000);++ assert.ok(h.live.hung > 0, 'Chat did ask the hung Live (and did not wait for it)');++ h.live.hang = false;++});++++t.run(async () => { if (h && h.close) await h.close(); });nnew file mode 100644@@@ -0,0 +1,356 @@++'use strict';++/**++ * Room kinds and roles (WS-I task 4, server/rooms/rooms.js): community, call and system rooms; the++ * roles owner, mod, speaker, participant, member, viewer and blocked with what each may do per kind++ * (the whole matrix, public and private, signed out, staff, a site ban); roles belong to their kind and++ * mods are the owner's; joining gives the kind's role. Call rooms (server/calls/call-server.js): the++ * room decides who is in the call (private rooms look missing, blocked people are refused) and who++ * talks (listeners are force-muted and cannot unmute); a role change reaches a running call at once;++ * a ban in the call blocks the person in the room; room calls stay out of the voice-channel list.++ * Attachments: a room's manager attaches it to a Community space with their own token, idempotently,++ * and detaches it; nobody else can, and a private room stays a 404 to outsiders.++ */++const assert = require('assert');++const WebSocket = require('ws');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('room kinds');++let h, rooms;++let own, md, spk, par, mem, vw, blk, out, staff, banned, bot;++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });++++function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {++ return new Promise((resolve, reject) => {++ const qs = new URLSearchParams({ channelId });++ if (token) qs.set('token', token);++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });++ const all = [];++ const waiters = [];++ ws.on('message', (d) => {++ let m; try { m = JSON.parse(d.toString()); } catch { return; }++ all.push(m);++ for (const w of [...waiters]) if (w.pred(m)) { waiters.splice(waiters.indexOf(w), 1); clearTimeout(w.timer); w.resolve(m); }++ });++ ws.all = all;++ ws.next = (pred, ms = 3000) => {++ const hit = all.find((m) => pred(m) && !m.__taken);++ if (hit) { hit.__taken = true; return Promise.resolve(hit); }++ return new Promise((res, rej) => {++ const w = { pred: (m) => { if (pred(m)) { m.__taken = true; return true; } return false; }, resolve: res };++ w.timer = setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); rej(new Error(`timed out waiting (got: ${JSON.stringify(all.map((x) => x.type).slice(-12))})`)); }, ms);++ waiters.push(w);++ });++ };++ ws.none = async (pred, ms = 300) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };++ ws.sendJson = (o) => ws.send(JSON.stringify(o));++ ws.closed = new Promise((r) => ws.on('close', () => r(true)));++ ws.on('open', () => resolve(ws));++ ws.on('error', reject);++ });++}++async function joinCall(opts) {++ const ws = await callWs(opts);++ const m = await ws.next((x) => x.type === 'welcome' || x.type === 'error');++ ws.welcome = m.type === 'welcome' ? m : null;++ ws.error = m.type === 'error' ? m.message : null;++ return ws;++}++const bye = async (...sockets) => { for (const ws of sockets) if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } await h.sleep(30); };++++t('boot (CHAT_CALLS=1)', async () => {++ h = await boot({ env: { CHAT_CALLS: '1' } });++ rooms = require('../server/rooms/rooms');++ const mk = (name, extra = {}) => h.addUser(name, { subject: ids.newId('user'), ...extra });++ own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');++ blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');++ bot = h.addUser('bot1', { apiScopes: ['chat'] });++ for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('the matrix: what every role may do in every kind, public and private', async () => {++ // read, post, call join, talk, moderate, manage++ const EXPECT = {++ community: { owner: 'rp--mM', mod: 'rp--m-', member: 'rp----', viewer: 'r-----', blocked: '------' },++ call: { owner: 'rpjtmM', mod: 'rpjtm-', speaker: 'rpjt--', participant: 'rpj---', viewer: 'r-j---', blocked: '------' },++ system: { owner: 'rp--mM', mod: 'rp--m-', viewer: 'r-----', blocked: '------' },++ };++ const byRole = { mod: md, speaker: spk, participant: par, member: mem, viewer: vw, blocked: blk };++ const flags = (a) => ['read', 'post', 'join', 'talk', 'moderate', 'manage'].map((k, i) => (a[k] ? 'rpjtmM'[i] : '-')).join('');++ for (const kind of ['community', 'call', 'system']) {++ for (const visibility of ['public', 'private']) {++ const owner = kind === 'system' ? staff : own;++ const room = rooms.bySlug(rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility }).slug);++ assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);++ for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') rooms.setRole(room, owner, byRole[role].id, role);++ for (const [role, want] of Object.entries(EXPECT[kind])) {++ const who = role === 'owner' ? owner : byRole[role];++ const a = rooms.access(room, who);++ assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);++ // A system room's owner is chat staff: they may post and manage anyway.++ assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);++ }++ const pub = visibility === 'public';++ const listen = kind === 'call' && pub ? 'j' : '-';++ assert.strictEqual(flags(rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);++ assert.strictEqual(flags(rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);++ if (kind !== 'system') assert.strictEqual(flags(rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);++ else assert.strictEqual(flags(rooms.access(room, staff)), 'rp--mM', 'system: staff post');++ }++ }++ // A site ban: reading stays, posting and talking go, whatever the role.++ const room = rooms.bySlug(rooms.create(own, { name: 'Banned Test', kind: 'call' }).slug);++ rooms.setRole(room, own, banned.id, 'speaker');++ assert.strictEqual(flags(rooms.access(room, banned)), 'rpjt--');++ h.live.addBan({ user_id: banned.id });++ await h.ctx.invalidateBans();++ assert.strictEqual(flags(rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');++ h.live.clearBans();++ await h.ctx.invalidateBans();++});++++t('roles belong to their kind, mods are the owner\'s, and joining gives the kind\'s role', async () => {++ let r = await api('POST', '/', own, { name: 'Open Stage', kind: 'call' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.body.room.kind, r.body.room.join_role, r.body.room.call_channel], ['call', 'participant', 'room-open-stage']);++ assert.deepStrictEqual([(await api('POST', '/', own, { name: 'My News', kind: 'system' })).status, (await api('POST', '/', own, { name: 'My News', kind: 'system' })).body.code], [403, 'rooms.kind'], 'people do not make system rooms');++ assert.strictEqual((await api('POST', '/', staff, { name: 'OpenVibe News', kind: 'system' })).status, 201);++ assert.strictEqual((await api('POST', '/', own, { name: 'Odd Join', join_role: 'speaker' })).body.code, 'rooms.join_role', 'join_role is for call rooms');++ assert.strictEqual((await api('POST', '/', own, { name: 'Tea Talk' })).status, 201);++++ r = await api('POST', '/tea-talk/members', own, { username: 'speaker1', role: 'speaker' });++ assert.deepStrictEqual([r.status, r.body.code], [422, 'rooms.role_kind'], 'no speakers in a community room');++ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'member1', role: 'member' })).body.code, 'rooms.role_kind', 'no members in a call room');++ assert.strictEqual((await api('POST', '/openvibe-news/members', staff, { username: 'part1', role: 'participant' })).body.code, 'rooms.role_kind');++++ // Joining: community → member, call → the room's join role, system → viewer.++ assert.strictEqual((await api('POST', '/tea-talk/join', mem)).body.role, 'member');++ assert.strictEqual((await api('POST', '/open-stage/join', par)).body.role, 'participant');++ assert.strictEqual((await api('POST', '/openvibe-news/join', out)).body.role, 'viewer');++ r = await api('PATCH', '/open-stage', own, { join_role: 'speaker' });++ assert.strictEqual(r.body.room.join_role, 'speaker');++ assert.strictEqual((await api('POST', '/open-stage/join', spk)).body.role, 'speaker');++ assert.strictEqual((await api('PATCH', '/tea-talk', own, { join_role: 'speaker' })).body.code, 'rooms.join_role');++ await api('PATCH', '/open-stage', own, { join_role: 'participant' });++++ // Mods: the owner appoints them; a mod sets the other roles but not mods.++ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'mod1', role: 'mod' })).body.role, 'mod');++ r = await api('POST', '/open-stage/members', md, { username: 'part1', role: 'speaker' });++ assert.deepStrictEqual([r.status, r.body.role], [200, 'speaker'], 'a mod makes speakers');++ assert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');++ assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');++ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });++ const log = h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id").map((x) => x.action_type);++ assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);++++ // Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.++ assert.strictEqual((await api('POST', '/openvibe-news/messages', out, { message: 'can I?' })).body.code, 'rooms.read_only');++ assert.strictEqual((await api('POST', '/openvibe-news/messages', staff, { message: 'Maintenance tonight at 22:00 UTC.' })).status, 201);++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'viewer' });++ assert.strictEqual((await api('POST', '/tea-talk/messages', vw, { message: 'hello' })).body.code, 'rooms.read_only');++ assert.strictEqual((await api('POST', '/open-stage/messages', par, { message: 'participants write' })).status, 201);++ assert.strictEqual((await api('POST', '/open-stage/join', vw)).status, 200);++ await api('POST', '/open-stage/members', own, { username: 'viewer1', role: 'viewer' });++ assert.strictEqual((await api('POST', '/open-stage/messages', vw, { message: 'viewers do not' })).body.code, 'rooms.read_only');++++ // What the room page reads: the caller's powers and the call.++ r = await api('GET', '/open-stage', par);++ assert.deepStrictEqual(r.body.can, { read: true, post: true, join: true, talk: false, moderate: false, manage: false });++ assert.deepStrictEqual([r.body.call.enabled, r.body.call.channel, r.body.call.participants], [true, 'room-open-stage', 0]);++ assert.strictEqual((await api('GET', '/tea-talk', null)).body.call, undefined, 'community rooms have no call');++++ // last_seen_at: moderators see it, others do not.++ const seenMod = (await api('GET', '/open-stage/members', own)).body.members.find((m) => m.username === 'part1');++ assert.ok(seenMod.last_seen_at, 'posting counts as seen');++ assert.ok(!('last_seen_at' in (await api('GET', '/open-stage/members', par)).body.members[0]), 'presence is for moderators');++});++++t('call rooms: the room decides who is in the call and who talks', async () => {++ const ownWs = await joinCall({ channelId: 'room-open-stage', token: own.token });++ assert.ok(ownWs.welcome, ownWs.error);++ assert.deepStrictEqual([ownWs.welcome.room, ownWs.welcome.roomRole, ownWs.welcome.canTalk, ownWs.welcome.canModerate], ['open-stage', 'owner', true, true]);++++ const parWs = await joinCall({ channelId: 'room-open-stage', token: par.token, ip: '203.0.113.31' });++ assert.deepStrictEqual([parWs.welcome.roomRole, parWs.welcome.canTalk, parWs.welcome.canModerate], ['participant', false, false]);++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);++ const joined = await ownWs.next((m) => m.type === 'peer-joined');++ assert.deepStrictEqual([joined.roomRole, joined.canTalk, joined.forceMuted], ['participant', false, true], 'everyone sees who may talk');++++ // Signalling works both ways; a listener's own unmute is ignored.++ parWs.sendJson({ type: 'offer', targetPeerId: ownWs.welcome.peerId, sdp: { type: 'offer', sdp: 'v=0 listener' } });++ assert.strictEqual((await ownWs.next((m) => m.type === 'offer')).sdp.sdp, 'v=0 listener');++ parWs.sendJson({ type: 'mute', muted: false });++ assert.ok(await ownWs.none((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId), 'a listener stays muted');++++ // Anyone may listen in a public call room; a moderator cannot lift a listener's mute (make them a speaker instead).++ const anonWs = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.32' });++ assert.deepStrictEqual([anonWs.welcome.canTalk, anonWs.welcome.roomRole], [false, null]);++ ownWs.sendJson({ type: 'force-mute', targetPeerId: anonWs.welcome.peerId, forceMuted: false });++ assert.ok(await anonWs.none((m) => m.type === 'force-muted' && m.forceMuted === false));++++ // Room calls are not in Live's voice-channel list, but the room's own page shows them.++ const list = await h.http('GET', '/api/streams/voice-channels');++ assert.ok(!JSON.stringify(list.body).includes('room-open-stage'), 'not in the voice sidebar');++ assert.strictEqual((await api('GET', '/open-stage', null)).body.call.participants, 3);++++ // Private call rooms look missing to outsiders; invited people get in; blocked people never do.++ await api('POST', '/', own, { name: 'Back Stage', kind: 'call', visibility: 'private' });++ let ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });++ assert.strictEqual(ws.error, 'Voice channel not found');++ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'participant' });++ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });++ assert.ok(ws.welcome, 'invited');++ await bye(ws);++ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'blocked' });++ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });++ assert.strictEqual(ws.error, 'Voice channel not found');++ ws = await joinCall({ channelId: 'room-tea-talk', token: own.token, ip: '203.0.113.34' });++ assert.strictEqual(ws.error, 'Voice channel not found', 'only call rooms have a call');++ await bye(anonWs);++ h._stage = { ownWs, parWs };++});++++t('a role change reaches the running call at once; a ban in the call blocks in the room', async () => {++ const { ownWs, parWs } = h._stage;++ let r = await api('POST', '/open-stage/members', own, { username: 'part1', role: 'speaker' });++ assert.strictEqual(r.body.role, 'speaker');++ const role = await parWs.next((m) => m.type === 'room-role');++ assert.deepStrictEqual([role.role, role.canTalk], ['speaker', true]);++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === false);++ const upd = await ownWs.next((m) => m.type === 'peer-updated' && m.peerId === parWs.welcome.peerId);++ assert.deepStrictEqual([upd.roomRole, upd.canTalk], ['speaker', true]);++ parWs.sendJson({ type: 'mute', muted: false });++ assert.strictEqual((await ownWs.next((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId)).muted, false, 'a speaker unmutes');++++ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'viewer' });++ assert.strictEqual((await parWs.next((m) => m.type === 'room-role')).canTalk, false, 'demoted: listening again');++ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);++++ await api('POST', '/open-stage/members', own, { username: 'part1', role: 'blocked' });++ await parWs.closed;++ assert.ok(parWs.all.some((m) => m.type === 'error'), 'told before being dropped');++ await ownWs.next((m) => m.type === 'peer-left' && m.peerId === parWs.welcome.peerId);++++ // A ban from inside the call is the room's block.++ await api('POST', '/open-stage/members', own, { username: 'speaker1', role: 'speaker' });++ const spkWs = await joinCall({ channelId: 'room-open-stage', token: spk.token, ip: '203.0.113.35' });++ assert.strictEqual(spkWs.welcome.canTalk, true);++ ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });++ await spkWs.closed;++ const room = rooms.bySlug('open-stage');++ assert.strictEqual(rooms.access(room, spk).role, 'blocked');++ assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');++++ // Turning the room private drops people who are not members (a guest listening).++ const guest = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.36' });++ assert.ok(guest.welcome);++ r = await api('PATCH', '/open-stage', own, { visibility: 'private' });++ assert.strictEqual(r.status, 200, r.text);++ await guest.closed;++ assert.ok(ownWs.readyState === WebSocket.OPEN, 'the owner stays');++ await bye(ownWs);++});++++t('attachments: a manager attaches with their own token, idempotently; detach; nobody else can', async () => {++ let r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);++ r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });++ assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');++ assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'").n, 1);++ assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');++ assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');++ assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');++ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'discord', resource: 'x' })).body.code, 'rooms.attach_service');++ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: '../evil' })).body.code, 'rooms.attach_resource');++ assert.strictEqual((await api('POST', '/tea-talk/attachments', null, { service: 'community', resource: 'help' })).status, 401);++ r = await api('GET', '/tea-talk/attachments', own);++ assert.deepStrictEqual(r.body.attachments.map((a) => [a.service, a.resource, a.title]), [['community', 'general', 'General']]);++ assert.strictEqual((await api('GET', '/tea-talk/attachments', mem)).status, 403);++ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/general', mem)).status, 403, 'nor detach');++ r = await api('DELETE', '/tea-talk/attachments/community/general', own);++ assert.deepStrictEqual([r.status, r.body.removed], [200, true]);++ r = await api('DELETE', '/tea-talk/attachments/community/general', own);++ assert.deepStrictEqual([r.status, r.body.removed], [200, false], 'detaching twice is fine');++ // Chat staff manage every room, so they may attach one too; whoever attached may detach.++ assert.strictEqual((await api('POST', '/tea-talk/attachments', staff, { service: 'community', resource: 'help' })).status, 201);++ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/help', staff)).body.removed, true);++});++++t('the site: kinds on the rooms page, the call panel, roles and attachments in settings', async () => {++ const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };++ let r = await page('/rooms', own);++ assert.match(r.text, /name="kind" value="call"/);++ assert.ok(!r.text.includes('value="system"'), 'people are not offered system rooms');++ assert.match((await page('/rooms', staff)).text, /name="kind" value="system"/);++ assert.match((await page('/rooms', null)).text, /<span class="oc-badge oc-kind-system">announcements<\/span>/);++++ r = await page('/r/open-stage', own);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<span class="oc-badge oc-kind-call">call<\/span>/);++ assert.match(r.text, /id="oc-call-join" hidden>Join the call</);++ assert.match(r.text, /\/web\/call\.js\?v=/);++ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);++ assert.deepStrictEqual(cfg.chat.call, { channel: 'room-open-stage', join: true, talk: true });++ assert.strictEqual((await page('/r/open-stage', out)).status, 404, 'private now: not there for outsiders');++ await api('POST', '/open-stage/members', own, { username: 'member1', role: 'participant' });++ r = await page('/r/open-stage', mem);++ assert.match(r.text, /id="oc-call-join" hidden>Listen in</);++ assert.match(r.text, /you are a participant/);++++ r = await page('/r/open-stage/settings', own);++ for (const role of ['moderator', 'speaker', 'participant', 'viewer', 'blocked']) assert.ok(r.text.includes(`>${role}</option>`), role);++ assert.ok(!r.text.includes('>member</option>'), 'call rooms have no members, they have participants');++ assert.match(r.text, /name="join_role"/);++ assert.match(r.text, /Speakers, moderators and the owner talk in the call/);++ assert.match(r.text, /· seen <time/, 'moderators see when people were last here');++ await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General <b>' });++ r = await page('/r/tea-talk/settings', own);++ assert.match(r.text, /<a href="https:\/\/openvibe\.community\/s\/general">General <b><\/a>/);++ assert.match(r.text, /action="\/r\/tea-talk\/attachments\/community\/general\/detach"/);++ assert.ok(!(await page('/r/tea-talk', own)).text.includes('openvibe.community/s/general'), 'the room page itself makes no claim about the space');++++ r = await page('/r/openvibe-news', out);++ assert.match(r.text, /Announcements from OpenVibe staff/);++ assert.ok(!r.text.includes('id="oc-compose"'));++ assert.match((await page('/r/openvibe-news', staff)).text, /placeholder="Post an announcement"/);++});++++t('chat sockets following a room learn their new role at once, or leave', async () => {++ const ws = await h.ws({ token: vw.token });++ ws.sendJson({ type: 'join_room', room: 'tea-talk' });++ const joined = await ws.next((m) => m.type === 'room_joined');++ assert.deepStrictEqual([joined.kind, joined.role, joined.can.post], ['community', 'viewer', false]);++ ws.sendJson({ type: 'room_message', message: 'viewers cannot' });++ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.read_only');++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'member' });++ const acc = await ws.next((m) => m.type === 'room_access');++ assert.deepStrictEqual([acc.role, acc.can.post], ['member', true]);++ ws.sendJson({ type: 'room_message', message: 'members can' });++ await ws.next((m) => m.type === 'room_message' && m.message.message === 'members can');++ await api('PATCH', '/tea-talk', own, { visibility: 'private' });++ assert.strictEqual((await ws.next((m) => m.type === 'room_access')).role, 'member', 'private now, but a member: still reading');++ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'none' });++ await ws.next((m) => m.type === 'room_left');++ ws.close();++});++++t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {++ const crypto = require('crypto');++ const turn = require('../server/net/turn');++ assert.deepStrictEqual((await h.http('GET', '/api/chat/ice-servers')).body.iceServers, [{ urls: 'stun:stun.l.google.com:19302' }], 'no TURN configured: STUN only');++ const at = Date.UTC(2026, 8, 26, 12);++ const eph = turn.iceServers('u7', turn.turnConfig({ TURN_URL: 'turn:turn.example.org:3478', TURN_AUTH_SECRET: 's3cret' }), at);++ const user = `${Math.floor(at / 1000) + turn.TTL_SECONDS}:u7`;++ assert.deepStrictEqual(eph.iceServers.slice(1), [++ { urls: 'turn:turn.example.org:3478', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },++ { urls: 'turn:turn.example.org:3478?transport=tcp', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },++ ]);++ assert.strictEqual(eph.ephemeral, true);++ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn://turn.example.org' })).iceServers.length, 1, 'a turn: URL without credentials is left out');++ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn:turn.example.org', TURN_USERNAME: 'u', TURN_CREDENTIAL: 'p' })).iceServers[1].username, 'u');++ assert.strictEqual(turn.normalizeTurnUrl('turn:user:[email protected]'), '', 'no credentials inside the URL');++ assert.strictEqual(turn.normalizeTurnUrl('https://turn.example.org'), '');++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -53,7 +53,9 @@ t('private rooms: invisible to outsiders, members by invitation', async () => {assert.strictEqual(r.status, 201);assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);-- assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 403);++ assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 404, 'joining says no more than a missing room would');++ assert.strictEqual((await api('POST', '/no-such-room/join', bob)).status, 404);++ assert.strictEqual((await api('POST', '/secret-club/messages', bob, { message: 'hi' })).status, 404);assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });
@@ -89,6 +89,15 @@ SOUNDS_PATH=./data/sounds
@@ -89,6 +89,15 @@ SOUNDS_PATH=./data/sounds
#CHAT_CALLS=1
#CHAT_CALLS=1
# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".
# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".
#CALL_RING_TIMEOUT_MS=45000
#CALL_RING_TIMEOUT_MS=45000
+# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.
+# With TURN_AUTH_SECRET (coturn use-auth-secret, the same value as Live's) credentials expire after an
+# hour; otherwise the static pair. Production: the same TURN_URL / TURN_AUTH_SECRET as live.env.
+#TURN_URL=turn:turn.openvibe.live
+#TURN_AUTH_SECRET=
+#TURN_USERNAME=
+#TURN_CREDENTIAL=
+# Where a room's Community attachments link (room settings page).
+#OV_COMMUNITY_URL=https://openvibe.community
# Optional diagnostics
# Optional diagnostics
#DEBUG_DM_DELIVERY=1
#DEBUG_DM_DELIVERY=1
@@ -179,6 +179,69 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
@@ -179,6 +179,69 @@ cutover** (`CHAT_CALLS=1`, `docs/calls-cutover.md`):
channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when it
channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when it
empties or closes. A restart closes what was open (reason `restart`).
empties or closes. A restart closes what was open (reason `restart`).
+### Rooms (openvibe.chat)
+
+Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and
+Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`,
+and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people
+join. Private rooms: members only; to anyone else they answer exactly like a room that does not exist
+(read, join, post, members, attachments, the call). Unread counts per room (`last_read_id`) and
+`last_seen_at` per member (moderators see it).
+
+Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the
+voice/video channel `room-<slug>` on `/ws/call`; anyone starts one) and **system** (announcements
+written by chat staff, read-only for people; only chat staff create them). Roles, and what they may do
+(`rooms.access()` is the one place this is decided):
+
+| Kind | Role | Read | Post | Join the call | Talk | Moderate | Manage |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| community | owner / mod / member / viewer | all | owner, mod, member | – | – | owner, mod | owner |
+| call | owner / mod / speaker / participant / viewer | all | owner, mod, speaker, participant | all | owner, mod, speaker | owner, mod | owner |
+| system | owner / mod / viewer | all | owner, mod | – | – | owner, mod | owner |
+| any | blocked | – | – | – | – | – | – |
+| any | no role | public rooms only | – | public call rooms (listening) | – | – | – |
+
+Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in
+calls; a site ban stops posting and talking. Joining gives the kind's role: member, the call room's
+`join_role` (participant by default; the owner may pick speaker or viewer) or viewer. The owner (and chat
+staff) appoint mods; owners and mods set every other role; roles outside the room's kind are refused
+(`rooms.role_kind`). Role changes on someone else are logged (`room_block`, `room_unblock`,
+`room_mod_add|remove`, `room_speaker_add|remove`) through the moderation log. A change reaches open sockets at
+once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the
+room (below).
+
+**Call rooms.** The call server makes the channel `room-<slug>` on first use and keeps it; it is not in Live's
+voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants,
+viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the
+camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead).
+Peers see `roomRole` / `canTalk` on each participant. Promoting, demoting or blocking someone sends them
+`room-role` and the force-mute state, or drops them; a ban inside the call is also the room's block; making
+the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by
+clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call
+client (`public/web/call.js`), shown when `CHAT_CALLS=1`; ICE comes from `GET /api/chat/ice-servers`
+(`server/net/turn.js`, Live's `turn.js` logic: STUN, plus TURN when `TURN_URL` is set with
+`TURN_AUTH_SECRET` or `TURN_USERNAME`/`TURN_CREDENTIAL`).
+
+**Attached to a Community space.** `POST /api/chat/rooms/:slug/attachments { service: "community",
+resource: <space slug>, title? }` (201 created, 200 already there), `DELETE
+/api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who
+manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused);
+the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own
+side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is
+involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public
+claim on the room page.
+
+**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox
+(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
+`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
+the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation
+counts unread messages and room messages. Everything is server-rendered and works without JavaScript.
+
+**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token
+and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and
+the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the
+forms, the core APIs and the socket with Live refusing connections and with Live hanging.
+
### VIP member badges
### VIP member badges
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+/* openvibe.chat call rooms: the room's voice call in the page. Audio only, peer to peer (a full mesh, as
+ Live's call client), signalled over /ws/call?channelId=room-<slug> with the protocol of
+ server/calls/call-server.js: the newcomer offers to everyone already in, the others answer. The room's
+ roles decide who talks (the server force-mutes listeners; this client never sends a microphone it may
+ not use). Names are set with textContent. The room's chat works without this file. */
+(function () {
+ 'use strict';
+ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
+ if (!cfg || !cfg.call || !cfg.call.join || !window.RTCPeerConnection || !window.WebSocket) return;
+ var $ = function (id) { return document.getElementById(id); };
+ var joinBtn = $('oc-call-join'), muteBtn = $('oc-call-mute'), leaveBtn = $('oc-call-leave');
+ var statusEl = $('oc-call-status'), peopleEl = $('oc-call-people'), countEl = $('oc-call-count'), howEl = $('oc-call-how');
+ if (!joinBtn) return;
+
+ var canTalk = !!cfg.call.talk;
+ var ws = null, myPeerId = null, localStream = null, muted = false, forceMuted = !canTalk, joined = false, connecting = false;
+ var attempt = 0; // each join; a teardown makes an attempt still starting stale
+ var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
+ var peers = new Map(); // peerId → { pc, audio, pending: [candidates] }
+ var people = new Map(); // peerId → participant info from the server
+
+ function status(text) { if (statusEl) statusEl.textContent = text || ''; }
+ function send(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+
+ function render() {
+ if (!peopleEl) return;
+ peopleEl.textContent = '';
+ if (!people.size) { peopleEl.appendChild(el('li', 'oc-muted', 'Nobody is in the call yet.')); }
+ people.forEach(function (p, id) {
+ var li = el('li', null, (p.displayName || p.username || p.anonId || 'Guest') + (id === myPeerId ? ' (you)' : ''));
+ var state = p.canTalk === false || p.forceMuted ? 'listening' : p.muted ? 'muted' : '';
+ if (state) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted', '(' + state + ')')); }
+ peopleEl.appendChild(li);
+ });
+ if (countEl) countEl.textContent = people.size ? '· ' + people.size + ' in' : '';
+ }
+ function setButtons() {
+ joinBtn.hidden = joined;
+ joinBtn.disabled = connecting;
+ leaveBtn.hidden = !joined;
+ muteBtn.hidden = !joined || !localStream;
+ muteBtn.disabled = forceMuted;
+ muteBtn.textContent = muted || forceMuted ? 'Unmute' : 'Mute';
+ muteBtn.setAttribute('aria-pressed', muted || forceMuted ? 'true' : 'false');
+ }
+ function applyMute() {
+ if (localStream) localStream.getAudioTracks().forEach(function (t) { t.enabled = !(muted || forceMuted); });
+ setButtons();
+ }
+
+ function closePeer(id) {
+ var p = peers.get(id);
+ if (!p) return;
+ try { p.pc.close(); } catch (e) { /* */ }
+ if (p.audio) { p.audio.srcObject = null; p.audio.remove(); }
+ peers.delete(id);
+ }
+ function createPeer(id, initiator) {
+ closePeer(id);
+ var pc = new RTCPeerConnection({ iceServers: iceServers });
+ var p = { pc: pc, audio: null, pending: [] };
+ peers.set(id, p);
+ if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
+ else if (initiator) pc.addTransceiver('audio', { direction: 'recvonly' });
+ pc.onicecandidate = function (e) { if (e.candidate) send({ type: 'ice-candidate', targetPeerId: id, candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate }); };
+ pc.ontrack = function (e) {
+ if (!p.audio) { p.audio = document.createElement('audio'); p.audio.autoplay = true; p.audio.hidden = true; document.body.appendChild(p.audio); }
+ p.audio.srcObject = (e.streams && e.streams[0]) || new MediaStream([e.track]);
+ };
+ pc.onconnectionstatechange = function () { if (pc.connectionState === 'failed') status('The connection to someone in the call failed. Leave and join again to retry.'); };
+ if (initiator) {
+ pc.createOffer().then(function (o) { return pc.setLocalDescription(o); })
+ .then(function () { send({ type: 'offer', targetPeerId: id, sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } }); })
+ .catch(function () { status('Could not start the call with someone.'); });
+ }
+ return p;
+ }
+ function flush(p) { var c; while ((c = p.pending.shift())) p.pc.addIceCandidate(c).catch(function () {}); }
+
+ function onMessage(m) {
+ switch (m.type) {
+ case 'welcome':
+ myPeerId = m.peerId; joined = true; connecting = false;
+ people.clear();
+ (m.participants || []).forEach(function (p) { people.set(p.peerId, p); });
+ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) { rejoin(m.canTalk); return; }
+ (m.participants || []).forEach(function (p) { if (p.peerId !== myPeerId) createPeer(p.peerId, true); });
+ status(canTalk ? 'You are in the call.' : 'You are listening.');
+ render(); applyMute();
+ break;
+ case 'peer-joined':
+ people.set(m.peerId, m); createPeer(m.peerId, false); render();
+ break;
+ case 'peer-left':
+ people.delete(m.peerId); closePeer(m.peerId); render();
+ break;
+ case 'peer-updated':
+ people.set(m.peerId, Object.assign(people.get(m.peerId) || {}, m)); render();
+ break;
+ case 'peer-muted': case 'peer-force-muted': {
+ var who = people.get(m.peerId);
+ if (who) { if (m.type === 'peer-muted') who.muted = !!m.muted; else who.forceMuted = !!m.forceMuted; render(); }
+ break;
+ }
+ case 'offer': {
+ var p = peers.get(m.fromPeerId) || createPeer(m.fromPeerId, false);
+ p.pc.setRemoteDescription(m.sdp)
+ .then(function () { flush(p); return p.pc.createAnswer(); })
+ .then(function (a) { return p.pc.setLocalDescription(a); })
+ .then(function () { send({ type: 'answer', targetPeerId: m.fromPeerId, sdp: { type: p.pc.localDescription.type, sdp: p.pc.localDescription.sdp } }); })
+ .catch(function () { status('Could not connect to someone in the call.'); });
+ break;
+ }
+ case 'answer': {
+ var q = peers.get(m.fromPeerId);
+ if (q) q.pc.setRemoteDescription(m.sdp).then(function () { flush(q); }).catch(function () {});
+ break;
+ }
+ case 'ice-candidate': {
+ var r = peers.get(m.fromPeerId);
+ if (!r || !m.candidate) break;
+ if (r.pc.remoteDescription) r.pc.addIceCandidate(m.candidate).catch(function () {});
+ else r.pending.push(m.candidate);
+ break;
+ }
+ case 'force-muted':
+ forceMuted = !!m.forceMuted; applyMute();
+ break;
+ case 'room-role':
+ // A moderator changed your role: a new speaker needs the microphone, a new listener gives it up.
+ if (typeof m.canTalk === 'boolean' && m.canTalk !== canTalk) rejoin(m.canTalk);
+ break;
+ case 'kicked': teardown('You were removed from the call.'); break;
+ case 'banned': teardown('You were banned from this call.'); break;
+ case 'call-ended': teardown('The call ended.'); break;
+ case 'replaced': teardown('You joined this call from another tab.'); break;
+ case 'error': teardown(m.message || 'The call is not available.'); break;
+ default: break;
+ }
+ }
+
+ function teardown(text) {
+ attempt++;
+ connecting = false;
+ peers.forEach(function (p, id) { closePeer(id); });
+ people.clear();
+ if (ws) { try { ws.close(); } catch (e) { /* */ } ws = null; }
+ if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; }
+ joined = false; myPeerId = null;
+ render(); setButtons();
+ if (text != null) status(text);
+ }
+ function rejoin(talk) {
+ canTalk = talk; forceMuted = !talk;
+ if (howEl) howEl.textContent = talk ? 'You can talk in this call.' : 'You are listening. The owner or a moderator can make you a speaker.';
+ teardown(talk ? 'You can talk now. Joining with your microphone…' : 'You are a listener now. Rejoining…');
+ join();
+ }
+
+ function join() {
+ var mine = ++attempt;
+ connecting = true; setButtons();
+ status('Joining…');
+ var ice = fetch('/api/chat/ice-servers', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && Array.isArray(d.iceServers) && d.iceServers.length) iceServers = d.iceServers; })
+ .catch(function () { /* STUN only */ });
+ var mic = canTalk && navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ ? navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false })
+ .then(function (s) { if (mine === attempt) localStream = s; else s.getTracks().forEach(function (t) { t.stop(); }); }, function () { status('No microphone: you will listen until you allow it and join again.'); })
+ : Promise.resolve();
+ Promise.all([ice, mic]).then(function () {
+ if (mine !== attempt) return;
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(cfg.call.channel));
+ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
+ var sock = ws;
+ ws.onclose = function () { if (sock === ws && mine === attempt) teardown(joined ? 'The call was disconnected. Join again to reconnect.' : 'Could not reach the call.'); };
+ });
+ }
+
+ joinBtn.hidden = false;
+ joinBtn.addEventListener('click', join);
+ leaveBtn.addEventListener('click', function () { teardown('You left the call.'); });
+ muteBtn.addEventListener('click', function () {
+ if (forceMuted) return;
+ muted = !muted; applyMute();
+ send({ type: 'mute', muted: muted });
+ });
+ window.addEventListener('pagehide', function () { if (joined) teardown(null); });
+})();
@@ -91,3 +91,22 @@ button[disabled] { opacity: .6; cursor: default; }
@@ -91,3 +91,22 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }
.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }
.oc-members form { margin-left: auto; }
.oc-members form { margin-left: auto; }
h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
+/* Room kinds, calls, the inbox */
+.oc-kind-call { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }
+.oc-kind-system { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }
+.oc-call { margin: 12px 0; padding: 10px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; }
+.oc-call h2 { margin: 0 0 4px; font-size: 1.05rem; }
+.oc-call-people { list-style: none; margin: 6px 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; }
+.oc-call-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
+.oc-call-controls button[hidden] { display: none; }
+.oc-call-status:empty { display: none; }
+.oc-tools { display: block; margin-top: 8px; }
+.oc-blocked { margin-top: 20px; }
+.oc-blocked summary { cursor: pointer; color: var(--oc-muted); }
+.oc-blocked .oc-members { margin-top: 8px; }
+.oc-members li form.oc-inline { margin-left: auto; }
+.oc-members select { max-width: 100%; }
+@media (max-width: 560px) {
+ .oc-members form { margin-left: 0; width: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
+ .oc-new select { flex: 1 1 140px; }
+}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few
- seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text
- is always set with textContent, never parsed as HTML. */
+/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs and the inbox
+ refresh every few seconds. Pages work without this file (server-rendered, forms post); it only adds
+ liveness. User text is always set with textContent, never parsed as HTML. */
(function () {
(function () {
'use strict';
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@ -211,6 +211,9 @@
@@ -211,6 +211,9 @@
showError(m.message);
showError(m.message);
} else if (m.type === 'room_left') {
} else if (m.type === 'room_left') {
showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
+ } else if (m.type === 'room_access' && m.room === cfg.room && m.can) {
+ // Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.
+ if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }
} else if (m.type === 'auth_revoked') {
} else if (m.type === 'auth_revoked') {
showError('You were signed out. Sign in again to keep chatting.');
showError('You were signed out. Sign in again to keep chatting.');
}
}
@@ -238,6 +241,42 @@
@@ -238,6 +241,42 @@
});
});
}
}
+ // ── The inbox: conversations and unread counts, refreshed while the page is open ──
+ function convItem(c, me) {
+ var li = el('li', 'oc-conv' + (c.unread_count ? ' oc-unread' : ''));
+ var a = el('a'); a.href = '/messages/' + encodeURIComponent(c.id);
+ var others = (c.participants || []).filter(function (p) { return p.id !== me; });
+ a.appendChild(el('strong', null, c.name || others.map(function (p) { return p.display_name || p.username; }).join(', ') || 'Just you'));
+ if (c.unread_count) { a.appendChild(document.createTextNode(' ')); a.appendChild(el('span', 'oc-badge', c.unread_count + ' new')); }
+ a.appendChild(el('span', 'oc-muted oc-last', c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet'));
+ li.appendChild(a);
+ if (c.last_message_at) {
+ var d = new Date(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at);
+ if (!isNaN(d)) { var t = el('time', 'oc-time', d.toISOString().slice(0, 10)); t.dateTime = d.toISOString(); li.appendChild(t); }
+ }
+ return li;
+ }
+ function startInbox() {
+ var list = document.getElementById('oc-convs');
+ var status = document.getElementById('oc-inbox-status');
+ var me = Number(cfg.me) || null;
+ function poll() {
+ if (document.hidden || !list) return;
+ fetch('/api/dm/conversations', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || !Array.isArray(d.conversations)) return;
+ var unread = 0;
+ list.textContent = '';
+ d.conversations.forEach(function (c) { unread += Number(c.unread_count) || 0; list.appendChild(convItem(c, me)); });
+ if (!d.conversations.length) list.appendChild(el('li', 'oc-muted', 'No conversations yet.'));
+ if (status) status.textContent = unread ? unread + ' unread' : 'All caught up';
+ }).catch(function () {});
+ }
+ setInterval(poll, 15000);
+ document.addEventListener('visibilitychange', poll);
+ }
+
if (input) input.addEventListener('keydown', function (e) {
if (input) input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
});
});
@@ -245,4 +284,5 @@
@@ -245,4 +284,5 @@
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'room') startRoom();
else if (cfg.view === 'room') startRoom();
+ else if (cfg.view === 'inbox') startInbox();
})();
})();
@@ -209,6 +209,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -209,6 +209,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
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'));
+ // ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
+ app.use('/api/chat/ice-servers', require('./net/turn').createIceRoutes());
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
@@ -24,6 +24,18 @@
@@ -24,6 +24,18 @@
* 'mic' — Microphone only
* 'mic' — Microphone only
* 'mic+cam' — Mic + optional camera
* 'mic+cam' — Mic + optional camera
* 'cam+mic' — Both mic and camera
* 'cam+mic' — Both mic and camera
+ *
+ * Call rooms (roadmap WS-I task 4, ../rooms/rooms.js): a room of kind `call` has the channel
+ * `room-<slug>`, made when someone first connects to it and kept (permanent) while the room exists.
+ * The room decides who is in: a person who may not read the room is refused (a private room looks
+ * missing), blocked people are refused, and the room's roles decide who talks: owner, mods and
+ * speakers talk; participants, viewers and people without a role (public rooms, anonymous too) join
+ * listen-only — force-muted with the camera forced off, and their own unmute is ignored. The room's
+ * owner and mods (and chat staff) moderate the call. A role change reaches the call at once
+ * (applyRoomAccess: a `room-role` frame, then the force-mute state); a ban in the call also blocks the
+ * person in the room. Room channels are not in the voice-channel list (Live's sidebar): openvibe.chat
+ * lists rooms. Signalling only: media is peer to peer, so listen-only is enforced by the clients
+ * honouring force-mute (as for every force-mute here), and every peer sees who may talk.
*/
*/
'use strict';
'use strict';
@@ -34,6 +46,7 @@ const { extractWsToken, authenticateWs } = require('../auth/auth');
@@ -34,6 +46,7 @@ const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
const lifecycle = require('./lifecycle');
const lifecycle = require('./lifecycle');
+const rooms = require('../rooms/rooms');
// Cosmetics are Live's (monetization/cosmetics); read through live-context, warmed when a socket joins.
// Cosmetics are Live's (monetization/cosmetics); read through live-context, warmed when a socket joins.
const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
@@ -43,6 +56,7 @@ const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never on
@@ -43,6 +56,7 @@ const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never on
const KICK_COOLDOWN_MS = 60 * 1000;
const KICK_COOLDOWN_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
const PUBLIC_CHANNEL_ID = 'public';
const MODES = ['mic', 'mic+cam', 'cam+mic'];
const MODES = ['mic', 'mic+cam', 'cam+mic'];
+const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;
class CallServer {
class CallServer {
constructor() {
constructor() {
@@ -124,6 +138,7 @@ class CallServer {
@@ -124,6 +138,7 @@ class CallServer {
listChannels(viewer = null) {
listChannels(viewer = null) {
const result = [];
const result = [];
for (const [id, ch] of this.channels) {
for (const [id, ch] of this.channels) {
+ if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar
if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const room = this.rooms.get(id);
const participants = [];
const participants = [];
@@ -134,6 +149,7 @@ class CallServer {
@@ -134,6 +149,7 @@ class CallServer {
}
}
_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
_canSeePrivate(ch, viewer) {
_canSeePrivate(ch, viewer) {
+ if (ch.roomSlug) { const room = this._roomOf(ch.id); return !!room && rooms.access(room, viewer).read; }
if (!ch.private) return true;
if (!ch.private) return true;
if (!viewer) return false;
if (!viewer) return false;
if (permissions.can(viewer, 'staff.moderation.calls')) return true;
if (permissions.can(viewer, 'staff.moderation.calls')) return true;
@@ -161,6 +177,29 @@ class CallServer {
@@ -161,6 +177,29 @@ class CallServer {
if (this._notifyTimer.unref) this._notifyTimer.unref();
if (this._notifyTimer.unref) this._notifyTimer.unref();
}
}
+ /** The call room behind a `room-<slug>` channel id, or null. */
+ _roomOf(channelId) {
+ const m = ROOM_CHANNEL.exec(String(channelId || ''));
+ if (!m) return null;
+ const room = rooms.bySlug(m[1]);
+ return room && room.kind === 'call' ? room : null;
+ }
+
+ /** The channel of a call room, made on first use and named after the room. → channel | null */
+ ensureRoomChannel(channelId) {
+ const room = this._roomOf(channelId);
+ if (!room) return null;
+ let ch = this.channels.get(channelId);
+ if (!ch) {
+ ch = {
+ id: channelId, name: room.name, mode: 'mic+cam', createdBy: room.owner_id, streamId: null, permanent: true,
+ roomSlug: room.slug, createdAt: Date.now(), maxParticipants: MAX_PARTICIPANTS,
+ };
+ this.channels.set(channelId, ch);
+ } else { ch.name = room.name; ch.createdBy = room.owner_id; }
+ return ch;
+ }
+
getChannel(channelId, viewer = null) {
getChannel(channelId, viewer = null) {
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
if (!ch || !this._canSeePrivate(ch, viewer)) return null;
if (!ch || !this._canSeePrivate(ch, viewer)) return null;
@@ -252,6 +291,7 @@ class CallServer {
@@ -252,6 +291,7 @@ class CallServer {
forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,
forceMuted: info.forceMuted || false, forceCameraOff: info.forceCameraOff || false,
speaking: info.speaking || false,
speaking: info.speaking || false,
nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,
nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null,
+ ...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),
};
};
}
}
@@ -271,6 +311,7 @@ class CallServer {
@@ -271,6 +311,7 @@ class CallServer {
// Legacy: bare number → stream-<N>
// Legacy: bare number → stream-<N>
const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
+ if (!this.channels.get(resolvedId)) this.ensureRoomChannel(resolvedId);
if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive
// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive
@@ -314,6 +355,16 @@ class CallServer {
@@ -314,6 +355,16 @@ class CallServer {
if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
+ // A call room: the room's roles decide who is in and who talks.
+ let roomAccess = null;
+ if (channel.roomSlug) {
+ const room = this._roomOf(resolvedId);
+ roomAccess = room ? rooms.access(room, user) : null;
+ if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
+ if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
+ }
+ const listenOnly = !!(roomAccess && !roomAccess.talk);
+
const peerId = this._generatePeerId();
const peerId = this._generatePeerId();
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
@@ -355,7 +406,10 @@ class CallServer {
@@ -355,7 +406,10 @@ class CallServer {
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
- const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };
+ const clientInfo = {
+ ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,
+ isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),
+ };
this.clients.set(ws, { channelId: resolvedId, peerId });
this.clients.set(ws, { channelId: resolvedId, peerId });
room.set(peerId, clientInfo);
room.set(peerId, clientInfo);
@@ -381,7 +435,12 @@ class CallServer {
@@ -381,7 +435,12 @@ class CallServer {
participants,
participants,
isStreamer: isStreamer || isChannelCreator,
isStreamer: isStreamer || isChannelCreator,
canModerate,
canModerate,
+ ...(channel.roomSlug ? { room: channel.roomSlug, roomRole: clientInfo.roomRole, canTalk: !listenOnly } : {}),
}));
}));
+ if (listenOnly) {
+ ws.send(JSON.stringify({ type: 'force-muted', forceMuted: true }));
+ ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: true }));
+ }
const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });
const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
@@ -409,6 +468,7 @@ class CallServer {
@@ -409,6 +468,7 @@ class CallServer {
if (!user) return false;
if (!user) return false;
if (permissions.can(user, 'staff.moderation.calls')) return true;
if (permissions.can(user, 'staff.moderation.calls')) return true;
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
+ if (ch?.roomSlug) { const room = this._roomOf(channelId); return !!room && rooms.access(room, user).moderate; }
if (ch?.createdBy === user.id) return true;
if (ch?.createdBy === user.id) return true;
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
return false;
return false;
@@ -429,6 +489,7 @@ class CallServer {
@@ -429,6 +489,7 @@ class CallServer {
}
}
case 'mute': {
case 'mute': {
const c = room.get(peerId); if (!c) break;
const c = room.get(peerId); if (!c) break;
+ if (c.listenOnly && !msg.muted) break; // a listener in a call room stays muted
c.muted = !!msg.muted;
c.muted = !!msg.muted;
const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });
const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
@@ -437,6 +498,7 @@ class CallServer {
@@ -437,6 +498,7 @@ class CallServer {
}
}
case 'camera-off': {
case 'camera-off': {
const c = room.get(peerId); if (!c) break;
const c = room.get(peerId); if (!c) break;
+ if (c.listenOnly && !msg.cameraOff) break;
c.cameraOff = !!msg.cameraOff;
c.cameraOff = !!msg.cameraOff;
const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });
const m = JSON.stringify({ type: 'peer-camera', peerId, cameraOff: c.cameraOff });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
@@ -469,10 +531,16 @@ class CallServer {
@@ -469,10 +531,16 @@ class CallServer {
if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
}
}
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
+ if (ch?.roomSlug) {
+ const callRoom = this._roomOf(channelId);
+ const a = callRoom ? rooms.access(callRoom, user) : null;
+ if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }
+ }
c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;
c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;
if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }
if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }
+ if (ch?.roomSlug) { this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }
const pInfo = this._buildParticipantInfo(peerId, c);
const pInfo = this._buildParticipantInfo(peerId, c);
if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
type: 'self-updated',
type: 'self-updated',
@@ -487,6 +555,7 @@ class CallServer {
@@ -487,6 +555,7 @@ class CallServer {
case 'force-mute': {
case 'force-mute': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
+ if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room instead
target.forceMuted = !!msg.forceMuted;
target.forceMuted = !!msg.forceMuted;
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-muted', forceMuted: target.forceMuted }));
const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });
const m = JSON.stringify({ type: 'peer-force-muted', peerId: msg.targetPeerId, forceMuted: target.forceMuted });
@@ -496,6 +565,7 @@ class CallServer {
@@ -496,6 +565,7 @@ class CallServer {
case 'force-camera-off': {
case 'force-camera-off': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
+ if (target.listenOnly && !msg.forceCameraOff) break;
target.forceCameraOff = !!msg.forceCameraOff;
target.forceCameraOff = !!msg.forceCameraOff;
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));
if (target.ws.readyState === WebSocket.OPEN) target.ws.send(JSON.stringify({ type: 'force-camera-off', forceCameraOff: target.forceCameraOff }));
const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });
const m = JSON.stringify({ type: 'peer-force-camera-off', peerId: msg.targetPeerId, forceCameraOff: target.forceCameraOff });
@@ -521,6 +591,11 @@ class CallServer {
@@ -521,6 +591,11 @@ class CallServer {
if (target.user?.id) {
if (target.user?.id) {
banSet.add(target.user.id); // backward compatibility with existing entries
banSet.add(target.user.id); // backward compatibility with existing entries
banSet.add(`u:${target.user.id}`);
banSet.add(`u:${target.user.id}`);
+ // In a call room the ban is the room's block, so it outlives this process.
+ const callRoom = this._roomOf(channelId);
+ if (callRoom && sender.user) {
+ try { rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }
+ }
} else {
} else {
if (target.anonId) banSet.add(`a:${target.anonId}`);
if (target.anonId) banSet.add(`a:${target.anonId}`);
if (target.ip) banSet.add(`ip:${target.ip}`);
if (target.ip) banSet.add(`ip:${target.ip}`);
@@ -596,6 +671,60 @@ class CallServer {
@@ -596,6 +671,60 @@ class CallServer {
getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }
getCallBans(channelId) { const b = this.callBans.get(channelId); return b ? [...b] : []; }
+ /**
+ * A call room's roles or visibility changed (../rooms/routes.js, the site): everyone in its call gets
+ * what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer
+ * read the room (blocked, removed from a private room) is dropped. → peers changed
+ */
+ applyRoomAccess(room) {
+ if (!room || room.kind !== 'call') return 0;
+ const channelId = `room-${room.slug}`;
+ const ch = this.channels.get(channelId);
+ if (ch) { ch.name = room.name; ch.createdBy = room.owner_id; }
+ const r = this.rooms.get(channelId);
+ if (!r) return 0;
+ let changed = 0;
+ for (const [pid, info] of [...r]) if (this._applyRoomAccessTo(channelId, pid, info, room)) changed++;
+ return changed;
+ }
+
+ /** One peer of a call room against the room's current roles. → whether anything changed */
+ _applyRoomAccessTo(channelId, peerId, info, room = null) {
+ const callRoom = room || this._roomOf(channelId);
+ const a = callRoom ? rooms.access(callRoom, info.user || null) : null;
+ const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };
+ if (!a || !a.read || !a.join) {
+ send({ type: 'error', message: 'You can no longer join this call' });
+ this._handleDisconnect(info.ws, channelId, peerId);
+ try { info.ws.close(); } catch { /* */ }
+ return true;
+ }
+ const listenOnly = !a.talk;
+ if (info.listenOnly === listenOnly && info.roomRole === a.role) return false;
+ info.roomRole = a.role;
+ info.listenOnly = listenOnly;
+ info.isChannelCreator = !!(info.user && callRoom.owner_id === info.user.id);
+ info.forceMuted = listenOnly;
+ info.forceCameraOff = listenOnly;
+ if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }
+ send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: this._canModerate(info.user, channelId) });
+ send({ type: 'force-muted', forceMuted: info.forceMuted });
+ send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });
+ const r = this.rooms.get(channelId);
+ if (r) {
+ const m1 = JSON.stringify({ type: 'peer-force-muted', peerId, forceMuted: info.forceMuted });
+ const m2 = JSON.stringify({ type: 'peer-force-camera-off', peerId, forceCameraOff: info.forceCameraOff });
+ const m3 = JSON.stringify({ type: 'peer-updated', ...this._buildParticipantInfo(peerId, info) });
+ for (const [pid, other] of r) {
+ if (other.ws.readyState !== WebSocket.OPEN) continue;
+ other.ws.send(m1); other.ws.send(m2);
+ if (pid !== peerId) other.ws.send(m3);
+ }
+ }
+ this._notifyChannelsChanged();
+ return true;
+ }
+
endCall(channelId, reason = 'ended') {
endCall(channelId, reason = 'ended') {
const r = this.rooms.get(channelId); if (!r) return;
const r = this.rooms.get(channelId); if (!r) return;
const entries = [...r.values()];
const entries = [...r.values()];
@@ -516,7 +516,7 @@ class ChatServer {
@@ -516,7 +516,7 @@ class ChatServer {
if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
client.roomId = room.id;
client.roomId = room.id;
client.roomSlug = room.slug;
client.roomSlug = room.slug;
- this.sendTo(ws, { type: 'room_joined', room: room.slug, role: a.role, can: { post: a.post, moderate: a.moderate } });
+ this.sendTo(ws, { type: 'room_joined', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
break;
break;
}
}
case 'leave_room':
case 'leave_room':
@@ -2432,6 +2432,23 @@ class ChatServer {
@@ -2432,6 +2432,23 @@ class ChatServer {
}
}
}
}
+ /**
+ * A room's roles or visibility changed: every socket following it learns what it may do now
+ * (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told
+ */
+ refreshRoomAccess(room) {
+ const rooms = require('../rooms/rooms');
+ let n = 0;
+ for (const [ws, client] of this.clients) {
+ if (!room || client.roomId !== room.id) continue;
+ const a = rooms.access(room, client.user || null);
+ n++;
+ if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }
+ this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
+ }
+ return n;
+ }
+
/** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */
/** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */
removeFromRoom(roomId, userId) {
removeFromRoom(roomId, userId) {
let n = 0;
let n = 0;
@@ -59,10 +59,16 @@ function publicRows(rows) {
@@ -59,10 +59,16 @@ function publicRows(rows) {
return rows;
return rows;
}
}
-/** Load the cosmetics/tags of every author in these rows (one batched call for the misses). */
+/**
+ * Load the cosmetics/tags of every author in these rows (one batched call for the misses), waiting at
+ * most DECOR_WAIT_MS: they are Live's, and history and sending never wait on a slow or hung Live.
+ */
+const DECOR_WAIT_MS = 1500;
async function loadDecor(rows) {
async function loadDecor(rows) {
const ids = (rows || []).map((m) => m && m.user_id).filter(Boolean);
const ids = (rows || []).map((m) => m && m.user_id).filter(Boolean);
- if (ids.length) { try { await ctx.ensureDecor(ids); } catch { /* history still renders */ } }
+ if (!ids.length) return;
+ let timer;
+ try { await Promise.race([ctx.ensureDecor(ids), new Promise((r) => { timer = setTimeout(r, DECOR_WAIT_MS); if (timer.unref) timer.unref(); })]); } catch { /* history still renders */ } finally { clearTimeout(timer); }
}
}
/**
/**
@@ -272,7 +278,9 @@ router.post('/send', requireAuth, async (req, res) => {
@@ -272,7 +278,9 @@ router.post('/send', requireAuth, async (req, res) => {
}
}
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
- try { await ctx.ensureDecor([req.user.id]); } catch { /* non-critical */ }
+ // Cosmetics are Live's and only decorate the line: wait for them briefly, never for a slow or
+ // hanging Live (openvibe.chat keeps working with Live down).
+ await loadDecor([{ user_id: req.user.id }]);
// Word filter
// Word filter
const wordFilter = require('./word-filter');
const wordFilter = require('./word-filter');
@@ -51,6 +51,8 @@ module.exports = {
@@ -51,6 +51,8 @@ module.exports = {
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
web: {
web: {
baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
+ // Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).
+ communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
},
},
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * ICE servers for calls (openvibe.chat's call rooms; the same logic as OpenVibe.Live server/net/turn.js
+ * and its GET /api/auth/ice-servers, so both sites hand out the same kind of list).
+ *
+ * GET /api/chat/ice-servers → { iceServers: [{ urls }, { urls, username, credential }…] }
+ *
+ * One STUN server, plus the TURN server when TURN_URL is set and can be authenticated. With
+ * TURN_AUTH_SECRET (coturn `use-auth-secret` with the same `static-auth-secret`) every list carries a
+ * credential that expires (TURN REST API: username `<expiry>:<tag>`, credential base64 HMAC-SHA1 of the
+ * username); otherwise the static TURN_USERNAME / TURN_CREDENTIAL pair; a turn: URL with neither is left
+ * out (a browser refuses such an entry outright). Nothing here asks another service.
+ */
+const crypto = require('crypto');
+const express = require('express');
+
+const TTL_SECONDS = 3600;
+const STUN = { urls: 'stun:stun.l.google.com:19302' };
+
+/** turn:host[:port][?query] or turns:…, no credentials in it; '' when unusable. */
+function normalizeTurnUrl(raw) {
+ const v = String(raw || '').trim();
+ if (!v) return '';
+ const m = /^(turns?):(?:\/\/)?([^/?#@\s]+)([?][^#\s]*)?$/i.exec(v);
+ if (!m) return '';
+ return `${m[1].toLowerCase()}:${m[2]}${m[3] || ''}`;
+}
+
+function turnConfig(env = process.env) {
+ return {
+ url: normalizeTurnUrl(env.TURN_URL),
+ secret: String(env.TURN_AUTH_SECRET || '').trim(),
+ username: String(env.TURN_USERNAME || '').trim(),
+ credential: String(env.TURN_CREDENTIAL || '').trim(),
+ };
+}
+
+function turnCredentials(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
+ if (cfg.secret) {
+ const username = `${Math.floor(now / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
+ return { username, credential: crypto.createHmac('sha1', cfg.secret).update(username).digest('base64'), ephemeral: true };
+ }
+ if (cfg.username && cfg.credential) return { username: cfg.username, credential: cfg.credential, ephemeral: false };
+ return null;
+}
+
+/** The list for a viewer (tag: u<id> or anon). */
+function iceServers(tag = 'anon', cfg = turnConfig(), now = Date.now()) {
+ const servers = [STUN];
+ let ephemeral = false;
+ if (cfg.url) {
+ const c = turnCredentials(tag, cfg, now);
+ if (c) {
+ const tcp = cfg.url.includes('?') ? `${cfg.url}&transport=tcp` : `${cfg.url}?transport=tcp`;
+ servers.push({ urls: cfg.url, username: c.username, credential: c.credential }, { urls: tcp, username: c.username, credential: c.credential });
+ ephemeral = c.ephemeral;
+ }
+ }
+ return { iceServers: servers, ephemeral };
+}
+
+function createIceRoutes() {
+ const { optionalAuth } = require('../auth/auth');
+ const router = express.Router();
+ router.get('/', optionalAuth, (req, res) => {
+ const { iceServers: list, ephemeral } = iceServers(req.user ? `u${req.user.id}` : 'anon');
+ res.set('Cache-Control', ephemeral ? 'private, max-age=300' : 'private, no-store').json({ iceServers: list });
+ });
+ return router;
+}
+
+module.exports = { createIceRoutes, iceServers, turnCredentials, normalizeTurnUrl, turnConfig, TTL_SECONDS };
@@ -1,18 +1,47 @@
@@ -1,18 +1,47 @@
'use strict';
'use strict';
/**
/**
- * Chat rooms (roadmap WS-I task 4, first release): rooms anyone can create on openvibe.chat, beside the
- * global room and the stream/channel rooms Live's chat already has.
+ * Chat rooms (roadmap WS-I task 4): rooms people create on openvibe.chat, beside the global room and
+ * the stream/channel rooms Live's chat already has. Three kinds:
*
*
- * rooms slug (URL id), name, topic, kind (community | system | call), visibility
- * (public: anyone reads, signed-in people join; private: members only), owner, slow mode
- * room_members role: owner | mod | member | blocked; last_read_id for unread counts
- * room_messages the messages (soft-deleted by their author, a room mod or chat staff)
+ * community a conversation anyone can start (public or invite-only)
+ * call the room a call happens in: its text chat, and a voice/video channel on /ws/call
+ * (channel id `room-<slug>`, server/calls/call-server.js) where the room's roles decide who
+ * talks and who listens
+ * system platform-authored: announcements written by chat staff, read-only for people
*
*
- * Moderation: the owner and mods delete messages and block members; site bans and chat staff
- * (staff.moderation.chat) apply in every room. Every staff/mod action is logged through
- * db.logModerationAction (scope room:<slug>), which also reports it to Network's audit log.
- * Nothing here talks to Live except the ban check (ctx.isUserBanned), so rooms keep working with
- * Live down. chat-server.js delivers messages live to sockets that joined the room (join_room).
+ * rooms slug (URL id), name, topic, kind, visibility (public: anyone reads, signed-in
+ * people join; private: members only), owner, slow mode, join_role (call rooms:
+ * the role people get when they join: speaker, participant or viewer)
+ * room_members role (below); last_read_id for unread counts; last_seen_at (last read or post)
+ * room_messages the messages (soft-deleted by their author, a room mod or chat staff)
+ * room_attachments where a room is attached elsewhere (a Community space): the room's side of the
+ * link, made by a person who manages the room (the other service keeps its own)
+ *
+ * Roles and what they may do (access() is the only place this is decided):
+ *
+ * kind role read post call: join talk moderate manage
+ * community owner yes yes - - yes yes
+ * mod yes yes - - yes -
+ * member yes yes - - - -
+ * viewer yes - - - - - (read-only member)
+ * call owner yes yes yes yes yes yes
+ * mod yes yes yes yes yes -
+ * speaker yes yes yes yes - -
+ * participant yes yes yes - - - (listens, writes in the text chat)
+ * viewer yes - yes - - - (listens and reads)
+ * system owner yes yes - - yes yes (only chat staff create them)
+ * mod yes yes - - yes -
+ * viewer yes - - - - - (everyone who joins)
+ * any blocked - - - - - -
+ * no role public room: read (and listen in a call room); private room: nothing, not even that it exists
+ * chat staff (staff.moderation.chat): read, moderate and manage every room, post in system rooms, talk in calls
+ * a site ban: no posting and no talking, whatever the role
+ *
+ * Owners and chat staff appoint mods; owners and mods set every other role. Every staff/mod action
+ * on someone else is logged through db.logModerationAction (scope room:<slug>), which also reports it to
+ * Network's audit log. Nothing here talks to Live except the ban check (ctx.isUserBanned, a cached
+ * read), so rooms keep working with Live down. chat-server.js delivers messages live to sockets that
+ * joined the room (join_room).
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
@@ -20,9 +49,27 @@ const wordFilter = require('../chat/word-filter');
@@ -20,9 +49,27 @@ const wordFilter = require('../chat/word-filter');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
-const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system']);
-const ROLES = ['owner', 'mod', 'member', 'blocked'];
-const KINDS = ['community', 'system', 'call'];
+const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);
+const KINDS = ['community', 'call', 'system'];
+const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];
+/** The roles each kind has (owner is never assigned: a room has one, its creator). */
+const KIND_ROLES = Object.freeze({
+ community: ['owner', 'mod', 'member', 'viewer', 'blocked'],
+ call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],
+ system: ['owner', 'mod', 'viewer', 'blocked'],
+});
+/** What joining a public room makes you (a call room's owner picks one of JOIN_ROLES_CALL). */
+const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer' });
+const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];
+const POSTERS = Object.freeze({
+ community: new Set(['owner', 'mod', 'member']),
+ call: new Set(['owner', 'mod', 'speaker', 'participant']),
+ system: new Set(['owner', 'mod']),
+});
+const TALKERS = new Set(['owner', 'mod', 'speaker']);
+const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };
+/** Services a room can be attached to, and what names the resource there. */
+const ATTACH_SERVICES = Object.freeze({ community: { resource: /^[a-z0-9][a-z0-9-]{1,39}$/, noun: 'space' } });
const MAX_TEXT = 2000;
const MAX_TEXT = 2000;
const MAX_ROOMS_PER_OWNER = 10;
const MAX_ROOMS_PER_OWNER = 10;
const RATE = { max: 6, windowMs: 10_000 };
const RATE = { max: 6, windowMs: 10_000 };
@@ -35,7 +82,8 @@ const fail = (status, code, message) => { throw new RoomError(status, code, mess
@@ -35,7 +82,8 @@ const fail = (status, code, message) => { throw new RoomError(status, code, mess
let ready = false;
let ready = false;
function ensureSchema() {
function ensureSchema() {
if (ready) return;
if (ready) return;
- db.getDb().exec(`
+ const d = db.getDb();
+ d.exec(`
CREATE TABLE IF NOT EXISTS rooms (
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
@@ -71,13 +119,29 @@ function ensureSchema() {
@@ -71,13 +119,29 @@ function ensureSchema() {
deleted_by INTEGER
deleted_by INTEGER
);
);
CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);
CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);
+ CREATE TABLE IF NOT EXISTS room_attachments (
+ room_id INTEGER NOT NULL,
+ service TEXT NOT NULL,
+ resource TEXT NOT NULL,
+ title TEXT,
+ attached_by INTEGER NOT NULL,
+ attached_by_subject TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (room_id, service, resource)
+ );
`);
`);
+ // Additive columns on tables the first release created (ADD COLUMN is a metadata change: safe live).
+ const cols = (t) => new Set(d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name));
+ if (!cols('rooms').has('join_role')) d.exec('ALTER TABLE rooms ADD COLUMN join_role TEXT');
+ if (!cols('room_members').has('last_seen_at')) d.exec('ALTER TABLE room_members ADD COLUMN last_seen_at DATETIME');
ready = true;
ready = true;
}
}
const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
+const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');
+const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);
function slugify(name) {
function slugify(name) {
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
@@ -86,7 +150,8 @@ function slugify(name) {
@@ -86,7 +150,8 @@ function slugify(name) {
function publicRoom(r, extra = {}) {
function publicRoom(r, extra = {}) {
if (!r) return null;
if (!r) return null;
return {
return {
- id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: r.kind, visibility: r.visibility,
+ id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,
+ ...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
...extra,
...extra,
@@ -98,17 +163,26 @@ function bySlug(slug) {
@@ -98,17 +163,26 @@ function bySlug(slug) {
return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
}
}
-/** What `user` may do in `room`: { read, post, moderate, role }. */
+/**
+ * What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.
+ * `join` and `talk` are the room's call (call rooms only).
+ */
function access(room, user) {
function access(room, user) {
+ const kind = kindOf(room);
const role = user ? roleOf(room.id, user.id) : null;
const role = user ? roleOf(room.id, user.id) : null;
const staff = isStaff(user);
const staff = isStaff(user);
const blocked = role === 'blocked';
const blocked = role === 'blocked';
const member = !!role && !blocked;
const member = !!role && !blocked;
+ const banned = siteBanned(user);
const read = !blocked && (room.visibility === 'public' || member || staff);
const read = !blocked && (room.visibility === 'public' || member || staff);
- return { role, read, post: !!user && member && !siteBanned(user), moderate: !!user && (role === 'owner' || role === 'mod' || staff), manage: !!user && (role === 'owner' || staff) };
+ const moderate = !!user && !blocked && (role === 'owner' || role === 'mod' || staff);
+ const post = !!user && read && !banned && ((member && POSTERS[kind].has(role)) || (kind === 'system' && staff));
+ const join = kind === 'call' && read;
+ const talk = join && !!user && !banned && ((member && TALKERS.has(role)) || staff);
+ return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
}
}
-function create(user, { name, slug, topic, visibility = 'public', kind = 'community' } = {}) {
+function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
ensureSchema();
ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@ -117,15 +191,18 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'commun
@@ -117,15 +191,18 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'commun
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');
if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');
- if (!KINDS.includes(kind) || (kind !== 'community' && !isStaff(user))) fail(422, 'rooms.kind', 'Only chat staff create system and call rooms');
+ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call or system room');
+ if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
+ if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;
const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;
if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
const id = db.transaction(() => {
const id = db.transaction(() => {
- const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?)', [s, n, t, kind, visibility, user.id, user.subject_id || null]);
- db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [r.lastInsertRowid, user.id]);
+ const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);
+ db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)", [r.lastInsertRowid, user.id]);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
@@ -136,23 +213,35 @@ function list(user, { limit = 50 } = {}) {
@@ -136,23 +213,35 @@ function list(user, { limit = 50 } = {}) {
ensureSchema();
ensureSchema();
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));
const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));
- const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id,
+ const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
FROM room_members m JOIN rooms r ON r.id = m.room_id
FROM room_members m JOIN rooms r ON r.id = m.room_id
WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
- ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread })) : [];
+ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })) : [];
return { public: pub, mine };
return { public: pub, mine };
}
}
+/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
+function unreadTotal(user) {
+ if (!user) return 0;
+ ensureSchema();
+ return (db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL
+ JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
+ WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
+}
+
+/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
function join(room, user) {
function join(room, user) {
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
const role = roleOf(room.id, user.id);
const role = roleOf(room.id, user.id);
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role) return role;
if (role) return role;
- if (room.visibility !== 'public' && !isStaff(user)) fail(403, 'rooms.private', 'This room is invite-only');
+ // A private room is not there for someone it has not invited (the same answer as a missing room).
+ if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');
const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
- db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id) VALUES (?, ?, 'member', ?)", [room.id, user.id, last]);
- return 'member';
+ const r = joinRoleOf(room);
+ db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', [room.id, user.id, r, last]);
+ return r;
}
}
function leave(room, user) {
function leave(room, user) {
@@ -212,7 +301,11 @@ function post(room, user, text, { now = Date.now() } = {}) {
@@ -212,7 +301,11 @@ function post(room, user, text, { now = Date.now() } = {}) {
const a = access(room, user);
const a = access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
- if (!a.post) fail(403, room.visibility === 'public' ? 'rooms.join_first' : 'rooms.private', room.visibility === 'public' ? 'Join the room to post' : 'This room is invite-only');
+ if (!a.post) {
+ if (!a.read) fail(404, 'rooms.not_found', 'No such room');
+ if (!a.role) fail(403, 'rooms.join_first', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Join the room to post');
+ fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');
+ }
const body = String(text || '').replace(/\r\n/g, '\n').trim();
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (!body) fail(422, 'rooms.empty', 'Write something first');
if (!body) fail(422, 'rooms.empty', 'Write something first');
if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);
if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);
@@ -227,7 +320,7 @@ function post(room, user, text, { now = Date.now() } = {}) {
@@ -227,7 +320,7 @@ function post(room, user, text, { now = Date.now() } = {}) {
const id = db.transaction(() => {
const id = db.transaction(() => {
const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
- db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
+ db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
announceMessage(room, user, Number(r.lastInsertRowid), message, now);
announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
@@ -252,7 +345,7 @@ function markRead(room, user, lastId) {
@@ -252,7 +345,7 @@ function markRead(room, user, lastId) {
if (!user) return;
if (!user) return;
const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const to = Math.min(top, Number(lastId) || top);
const to = Math.min(top, Number(lastId) || top);
- db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
+ db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
}
function deleteMessage(room, user, messageId) {
function deleteMessage(room, user, messageId) {
@@ -269,36 +362,50 @@ function deleteMessage(room, user, messageId) {
@@ -269,36 +362,50 @@ function deleteMessage(room, user, messageId) {
return m.id;
return m.id;
}
}
-/** Owner/mod actions on a member: invite (by user id), set a role (mod/member), block or unblock. */
+/**
+ * Owner/mod actions on a person: add them (a role, by user id), change their role, block, unblock or
+ * remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or
+ * chat staff's) to appoint and remove; everything else is any moderator's.
+ */
function setRole(room, actor, targetUserId, role) {
function setRole(room, actor, targetUserId, role) {
+ const kind = kindOf(room);
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
+ if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
+ if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);
const a = access(room, actor);
const a = access(room, actor);
+ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
const current = roleOf(room.id, targetUserId);
const current = roleOf(room.id, targetUserId);
if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
- if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
- if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
const target = ctx.getUserById(targetUserId);
const target = ctx.getUserById(targetUserId);
if (!target) fail(404, 'rooms.no_user', 'No such person');
if (!target) fail(404, 'rooms.no_user', 'No such person');
if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
- if (role === 'blocked' || current === 'blocked' || role === 'mod' || current === 'mod') {
- db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: role === 'blocked' ? 'room_block' : current === 'blocked' ? 'room_unblock' : role === 'mod' ? 'room_mod_add' : 'room_mod_remove', details: {} });
- }
+ const action = role === 'blocked' && current !== 'blocked' ? 'room_block'
+ : current === 'blocked' && role !== 'blocked' ? 'room_unblock'
+ : role === 'mod' && current !== 'mod' ? 'room_mod_add'
+ : current === 'mod' && role !== 'mod' ? 'room_mod_remove'
+ : role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
+ : current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
+ if (action) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });
return role;
return role;
}
}
-function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
+function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
const next = { ...room };
const next = { ...room };
if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
+ if (join_role != null && join_role !== '') {
+ if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
+ next.join_role = join_role;
+ }
db.transaction(() => {
db.transaction(() => {
- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);
+ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
if (room.visibility === 'public' && next.visibility === 'private') {
announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));
announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));
@@ -307,11 +414,61 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
@@ -307,11 +414,61 @@ function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
}
-function members(room) {
- return db.all(`SELECT m.user_id, m.role, u.username, u.display_name FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
- WHERE m.room_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'mod' THEN 1 WHEN 'member' THEN 2 ELSE 3 END, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
+/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
+function members(room, { seen = false } = {}) {
+ const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
+ return db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
+ WHERE m.room_id = ? ORDER BY ${order}, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
+}
+
+// ── Attachments: the room's side of a link from another service (a Community space) ──
+// The other service keeps the link (Community stores the room on the space) and checks its own side
+// (the space's owner or staff); Chat checks that the person attaching manages this room. So one person
+// holding both ends makes the link, with their own Network token: no service grant is involved.
+
+function attachmentShape(r) {
+ return { service: r.service, resource: r.resource, title: r.title || null, attached_by: r.attached_by, created_at: r.created_at };
+}
+
+/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */
+function attach(room, user, { service, resource, title } = {}) {
+ ensureSchema();
+ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
+ if (!access(room, user).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');
+ const svc = ATTACH_SERVICES[service];
+ if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);
+ const res = String(resource || '').toLowerCase();
+ if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);
+ const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;
+ const existing = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);
+ if (existing) {
+ if (t && t !== existing.title) db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);
+ return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };
+ }
+ db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);
+ return { attachment: attachmentShape(db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };
+}
+
+/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */
+function detach(room, user, service, resource) {
+ ensureSchema();
+ if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
+ const row = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);
+ if (!row) return { removed: false };
+ if (!access(room, user).manage && row.attached_by !== user.id) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');
+ db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);
+ return { removed: true };
+}
+
+function attachments(room) {
+ ensureSchema();
+ return db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id]).map(attachmentShape);
}
}
function _reset() { ready = false; _rate.clear(); }
function _reset() { ready = false; _rate.clear(); }
-module.exports = { ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById, RoomError, SLUG_RE, _reset };
+module.exports = {
+ ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
+ attach, detach, attachments, joinRoleOf, unreadTotal,
+ RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
+};
@@ -4,15 +4,23 @@
@@ -4,15 +4,23 @@
* chat-server.js carries the same rooms live (join_room / room_message).
* chat-server.js carries the same rooms live (join_room / room_message).
*
*
* GET / public rooms by activity + the caller's rooms (with unread counts)
* GET / public rooms by activity + the caller's rooms (with unread counts)
- * POST / create { name, slug?, topic?, visibility? }
- * GET /:slug the room and what the caller may do (404 when they may not read it)
- * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds? }
+ * POST / create { name, slug?, topic?, visibility?, kind?: community | call | system (staff),
+ * join_role?: speaker | participant | viewer (call rooms) }
+ * GET /:slug the room, what the caller may do (can: read/post/join/talk/moderate/manage)
+ * and, for a call room, its call (404 when they may not read it)
+ * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds?, join_role? }
* GET /:slug/messages ?before=<id> | ?after=<id>, &limit
* GET /:slug/messages ?before=<id> | ?after=<id>, &limit
* POST /:slug/messages { message }
* POST /:slug/messages { message }
* DELETE /:slug/messages/:id the author, a room moderator or chat staff
* DELETE /:slug/messages/:id the author, a room moderator or chat staff
* POST /:slug/join | /leave | /read { last_id? }
* POST /:slug/join | /leave | /read { last_id? }
- * GET /:slug/members
- * POST /:slug/members owner/mods: { username, role: member | mod | blocked | none }
+ * GET /:slug/members (moderators also get last_seen_at)
+ * POST /:slug/members owner/mods: { username, role } — one of the kind's roles (rooms.KIND_ROLES) or none
+ * GET /:slug/attachments managers: where the room is attached (a Community space)
+ * POST /:slug/attachments managers, with their own Network token: { service: 'community', resource: <space slug>, title? }
+ * DELETE /:slug/attachments/:service/:resource managers or whoever attached it (both idempotent)
+ *
+ * Role and room changes reach a running call at once (call-server.js applyRoomAccess): a new speaker
+ * may talk, a demoted one is muted, a blocked person is dropped.
*/
*/
const express = require('express');
const express = require('express');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -27,6 +35,25 @@ function send(res, err) {
@@ -27,6 +35,25 @@ function send(res, err) {
return res.status(500).json({ error: 'Something went wrong' });
return res.status(500).json({ error: 'Something went wrong' });
}
}
const chatServer = () => require('../chat/chat-server');
const chatServer = () => require('../chat/chat-server');
+/**
+ * After a role or room change: sockets following the room learn what they may do now, and a running
+ * call in it follows the room's roles (no-op when calls are off or nobody is in).
+ */
+function applyRoomChange(room) {
+ const fresh = rooms.bySlug(room.slug) || room;
+ try { chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }
+ try { require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }
+}
+/** The room's call as the room page shows it: on or off, and who is in. */
+function callOf(room) {
+ if (room.kind !== 'call') return undefined;
+ const enabled = !!require('../config').calls.enabled;
+ let people = [];
+ try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }
+ return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };
+}
+/** People only: attaching speaks for the person (API tokens and services cannot). */
+const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
function readable(req, res) {
function readable(req, res) {
@@ -46,12 +73,16 @@ router.post('/', requireAuth, (req, res) => {
@@ -46,12 +73,16 @@ router.post('/', requireAuth, (req, res) => {
router.get('/:slug', optionalAuth, (req, res) => {
router.get('/:slug', optionalAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
const a = rooms.access(room, req.user || null);
const a = rooms.access(room, req.user || null);
- res.set('Cache-Control', 'private, no-store').json({ room: rooms.publicRoom(room, { role: a.role }), can: { post: a.post, moderate: a.moderate, manage: a.manage } });
+ res.set('Cache-Control', 'private, no-store').json({
+ room: rooms.publicRoom(room, { role: a.role }),
+ can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
+ ...(room.kind === 'call' ? { call: callOf(room) } : {}),
+ });
});
});
router.patch('/:slug', requireAuth, (req, res) => {
router.patch('/:slug', requireAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
- try { res.json({ room: rooms.update(room, req.user, req.body || {}) }); } catch (err) { send(res, err); }
+ try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
});
});
router.get('/:slug/messages', optionalAuth, (req, res) => {
router.get('/:slug/messages', optionalAuth, (req, res) => {
@@ -83,12 +114,17 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
@@ -83,12 +114,17 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
router.post('/:slug/join', requireAuth, (req, res) => {
router.post('/:slug/join', requireAuth, (req, res) => {
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
- try { res.json({ ok: true, role: rooms.join(room, req.user) }); } catch (err) { send(res, err); }
+ try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
});
});
router.post('/:slug/leave', requireAuth, (req, res) => {
router.post('/:slug/leave', requireAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
- try { rooms.leave(room, req.user); chatServer().removeFromRoom(room.id, req.user.id); res.json({ ok: true }); } catch (err) { send(res, err); }
+ try {
+ rooms.leave(room, req.user);
+ chatServer().removeFromRoom(room.id, req.user.id);
+ applyRoomChange(room);
+ res.json({ ok: true });
+ } catch (err) { send(res, err); }
});
});
router.post('/:slug/read', requireAuth, (req, res) => {
router.post('/:slug/read', requireAuth, (req, res) => {
@@ -98,7 +134,8 @@ router.post('/:slug/read', requireAuth, (req, res) => {
@@ -98,7 +134,8 @@ router.post('/:slug/read', requireAuth, (req, res) => {
router.get('/:slug/members', optionalAuth, (req, res) => {
router.get('/:slug/members', optionalAuth, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
- res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room).filter((m) => m.role !== 'blocked' || rooms.access(room, req.user || null).moderate) });
+ const moderate = rooms.access(room, req.user || null).moderate;
+ res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });
});
});
router.post('/:slug/members', requireAuth, async (req, res) => {
router.post('/:slug/members', requireAuth, async (req, res) => {
@@ -109,9 +146,32 @@ router.post('/:slug/members', requireAuth, async (req, res) => {
@@ -109,9 +146,32 @@ router.post('/:slug/members', requireAuth, async (req, res) => {
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
- if (role === 'blocked' || role === 'none' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
+ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
+ applyRoomChange(room);
res.json({ ok: true, user_id: target.id, username: target.username, role });
res.json({ ok: true, user_id: target.id, username: target.username, role });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+router.get('/:slug/attachments', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ if (!rooms.access(room, req.user).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
+ res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });
+});
+
+router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try {
+ const b = req.body || {};
+ const out = rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });
+ res.status(out.created ? 201 : 200).json({ ...out, room: rooms.publicRoom(room) });
+ } catch (err) { send(res, err); }
+});
+
+router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
+});
+
module.exports = router;
module.exports = router;
+module.exports.callOf = callOf;
+module.exports.applyRoomChange = applyRoomChange;
@@ -35,11 +35,17 @@ function asset(rel) {
@@ -35,11 +35,17 @@ function asset(rel) {
return `/web/${rel}?v=${hashes.get(rel)}`;
return `/web/${rel}?v=${hashes.get(rel)}`;
}
}
+/** The site's links, with the signed-in person's unread counts (o.counts: { rooms, messages }). */
+function links(o) {
+ const counts = o.counts || {};
+ return LINKS.map((l) => ({ key: l.key, href: l.href, label: counts[l.key] > 0 ? `${l.label} (${counts[l.key] > 99 ? '99+' : counts[l.key]})` : l.label }));
+}
+
function navConfig(o, config) {
function navConfig(o, config) {
return {
return {
service: 'chat',
service: 'chat',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
+ links: links(o).map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -50,7 +56,8 @@ function navConfig(o, config) {
@@ -50,7 +56,8 @@ function navConfig(o, config) {
/**
/**
* o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
* o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
- * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)
+ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
+ * rooms/messages for the navigation), script / callScript (public/web/chat.js, call.js)
*/
*/
function renderPage(o) {
function renderPage(o) {
const { config } = o;
const { config } = o;
@@ -79,11 +86,12 @@ ${appIcon.headTags({ site: 'network' })}
@@ -79,11 +86,12 @@ ${appIcon.headTags({ site: 'network' })}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
+${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
</head>
</head>
<body class="${cls}"${scale}>
<body class="${cls}"${scale}>
<a class="oc-skip" href="#main">Skip to content</a>
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
-${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}
+${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
<main id="main" class="oc-main">
<main id="main" class="oc-main">
<noscript><p class="oc-account">${who}</p></noscript>
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
${o.body || ''}
@@ -6,8 +6,12 @@
@@ -6,8 +6,12 @@
* GET / global chat: the last messages server-rendered (readable without
* GET / global chat: the last messages server-rendered (readable without
* JavaScript); public/web/chat.js adds the live feed over /ws/chat
* JavaScript); public/web/chat.js adds the live feed over /ws/chat
* POST /send the composer without JavaScript (the same handler as POST /api/chat/send)
* POST /send the composer without JavaScript (the same handler as POST /api/chat/send)
- * GET /messages your conversations; POST /messages/new starts one by username
- * GET /messages/:id one conversation (participants only); POST /messages/:id replies
+ * GET /messages your inbox: conversations with unread counts, people you blocked;
+ * POST /messages/new starts one by username; POST /messages/unblock/:userId
+ * GET /messages/:id one conversation (participants only); POST /messages/:id replies;
+ * POST /messages/:id/block blocks the other person of a 1:1 conversation
+ * GET /rooms, /r/:slug rooms: community, call (a voice/video call in the page, public/web/call.js)
+ * and system (staff announcements); /r/:slug/settings for moderators
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
* GET /settings your portable chat settings (Network module chat.preferences); POST saves
* GET /updates what shipped on openvibe.chat (the shared changelog)
* GET /updates what shipped on openvibe.chat (the shared changelog)
* GET /robots.txt, /sitemap.xml
* GET /robots.txt, /sitemap.xml
@@ -16,6 +20,11 @@
@@ -16,6 +20,11 @@
* Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,
* Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,
* bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this
* bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this
* site (Origin check; the session cookie is SameSite=Lax).
* site (Origin check; the session cookie is SameSite=Lax).
+ *
+ * Working with Live down: nothing here waits on OpenVibe.Live. People are signed in from their Network
+ * token and the ctx_users projection (auth/network-session.js), and every page reads Chat's own
+ * tables; the only openvibe.live URLs are links to people's profiles (navigation, never fetched).
+ * test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -110,10 +119,15 @@ function createWebRoutes({ config }) {
@@ -110,10 +119,15 @@ function createWebRoutes({ config }) {
if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
next();
next();
};
};
+ const countsOf = (actor) => {
+ if (actor.kind !== 'user') return {};
+ const n = (fn) => { try { return Number(fn()) || 0; } catch { return 0; } };
+ return { messages: n(() => dm.getTotalUnread(actor.user.id)), rooms: n(() => require('../rooms/rooms').unreadTotal(actor.user)) };
+ };
const page = async (req, res, status, o) => {
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
const actor = o.actor || await viewerOf(req);
res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
- .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));
+ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: countsOf(actor), ...o }));
};
};
const needUser = async (req, res) => {
const needUser = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
@@ -179,14 +193,29 @@ ${composer}
@@ -179,14 +193,29 @@ ${composer}
return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
});
});
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
+ const blocked = dm.getBlockedUsers(me.id);
+ const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';
+ const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',
- body: `<h1>Messages</h1>${error}
+ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true,
+ page: { view: 'inbox', unread, me: me.id },
+ body: `<h1>Messages</h1>${error}${done}
+<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
-<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,
+<ul class="oc-convs" id="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>
+${blockedList}`,
});
});
});
});
+ router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const id = parseInt(req.params.userId, 10);
+ if (Number.isInteger(id) && id > 0) dm.unblockUser(actor.user.id, id);
+ const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';
+ res.redirect(303, back);
+ });
+
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
@@ -212,15 +241,36 @@ ${composer}
@@ -212,15 +241,36 @@ ${composer}
const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
try { dm.markRead(id, me.id); } catch { /* best effort */ }
try { dm.markRead(id, me.id); } catch { /* best effort */ }
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ // A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
+ const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;
+ const iBlocked = other ? dm.hasBlocked(me.id, other.id) : false;
+ const closed = other ? dm.isBlockedEither(me.id, other.id) : false;
+ const otherName = other ? esc(other.display_name || other.username) : '';
+ let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;
+ if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
+ else if (closed) composer = notice('error', 'You cannot message this person.');
+ const tools = other && !iBlocked ? `<form class="oc-inline oc-tools" method="post" action="/messages/${id}/block"><button type="submit" class="oc-link">Block ${otherName}</button></form>` : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
-<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,
+${composer}${tools}`,
});
});
});
});
+ router.post('/messages/:id/block', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const me = actor.user;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');
+ const conv = dm.getConversation(id);
+ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
+ if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);
+ dm.blockUser(me.id, others[0].id);
+ res.redirect(303, `/messages/${id}`);
+ });
+
router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {
router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {
if (!(await needUser(req, res))) return;
if (!(await needUser(req, res))) return;
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
@@ -304,34 +354,46 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -304,34 +354,46 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
// ── Rooms (server/rooms/) ──
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
+ const roomRoutes = require('../rooms/routes');
const roomsOrError = (fn) => async (req, res, next) => {
const roomsOrError = (fn) => async (req, res, next) => {
try { return await fn(req, res, next); } catch (err) {
try { return await fn(req, res, next); } catch (err) {
if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
return next(err);
return next(err);
}
}
};
};
- const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
+ const KIND_LABEL = { community: '', call: 'call', system: 'announcements' };
+ const ROLE_LABEL = { owner: 'owner', mod: 'moderator', speaker: 'speaker', participant: 'participant', member: 'member', viewer: 'viewer', blocked: 'blocked' };
+ const kindBadge = (r) => (KIND_LABEL[r.kind] ? ` <span class="oc-badge oc-kind-${esc(r.kind)}">${KIND_LABEL[r.kind]}</span>` : '');
+ const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
const roomMessage = (m, can, slug, me) => {
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
- return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? esc(m.profile_color) : 'inherit'}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
+ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
};
};
+ const applyRoomChange = (room) => roomRoutes.applyRoomChange(room);
router.get('/rooms', async (req, res) => {
router.get('/rooms', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const me = actor.kind === 'user' ? actor.user : null;
const me = actor.kind === 'user' ? actor.user : null;
const { public: pub, mine } = rooms.list(me);
const { public: pub, mine } = rooms.list(me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const staff = !!me && permissions.can(me, 'staff.moderation.chat');
const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
<h2>Start a room</h2>
<h2>Start a room</h2>
<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>
<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
+<fieldset class="oc-fieldset"><legend>Kind</legend>
+<label class="oc-check"><input type="radio" name="kind" value="community" checked> Chat room: people write to each other</label>
+<label class="oc-check"><input type="radio" name="kind" value="call"> Call room: a voice call with a text chat beside it</label>
+${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system"> Announcements: only OpenVibe staff post (staff)</label>' : ''}
+</fieldset>
+<label>In a call room, people who join <select name="join_role"><option value="participant">listen and write (participants)</option><option value="speaker">can talk (speakers)</option><option value="viewer">listen and read (viewers)</option></select></label>
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
<p><button type="submit">Create room</button></p>
<p><button type="submit">Create room</button></p>
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;
</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
- description: 'Chat rooms on OpenVibe: start one about anything, invite people, or join a public room.',
+ description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
body: `<h1>Rooms</h1>${error}
body: `<h1>Rooms</h1>${error}
${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}
${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}
<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>
<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>
@@ -342,7 +404,8 @@ ${create}`,
@@ -342,7 +404,8 @@ ${create}`,
router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
const b = req.body || {};
- const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public' });
+ const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
+ const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
@@ -356,28 +419,55 @@ ${create}`,
@@ -356,28 +419,55 @@ ${create}`,
return { actor, me, room, can };
return { actor, me, room, can };
};
};
+ /** A call room's call: who is in, and the button public/web/call.js turns into the call. */
+ function callPanel(room, can, me) {
+ const call = roomRoutes.callOf(room) || { enabled: false, participants: 0, people: [] };
+ if (!call.enabled) return `<section class="oc-call" aria-labelledby="oc-call-h"><h2 id="oc-call-h">Call</h2><p class="oc-muted">Calls are not available right now. The room's chat works as usual.</p></section>`;
+ const people = call.people.length
+ ? `<ul class="oc-call-people" id="oc-call-people">${call.people.map((p) => `<li>${esc(p.display_name || p.username || 'Guest')}${p.listening ? ' <span class="oc-muted">(listening)</span>' : ''}</li>`).join('')}</ul>`
+ : '<ul class="oc-call-people" id="oc-call-people"><li class="oc-muted">Nobody is in the call yet.</li></ul>';
+ const how = can.talk ? 'You can talk in this call.' : me ? 'You join listening. The owner or a moderator can make you a speaker.' : 'Anyone can listen. Sign in to be made a speaker.';
+ return `<section class="oc-call" aria-labelledby="oc-call-h" id="oc-call">
+<h2 id="oc-call-h">Call <span class="oc-muted oc-call-count" id="oc-call-count">${call.participants ? `· ${call.participants} in` : ''}</span></h2>
+<p class="oc-muted" id="oc-call-how">${how}</p>
+${people}
+<p class="oc-call-controls"><button type="button" id="oc-call-join" hidden>${can.talk ? 'Join the call' : 'Listen in'}</button> <button type="button" id="oc-call-mute" hidden aria-pressed="false">Mute</button> <button type="button" id="oc-call-leave" hidden>Leave the call</button></p>
+<p class="oc-muted oc-call-status" id="oc-call-status" role="status"></p>
+<noscript><p class="oc-muted">The call needs JavaScript; the room's chat works without it.</p></noscript>
+</section>`;
+ }
+
router.get('/r/:slug', async (req, res) => {
router.get('/r/:slug', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
const { actor, me, room, can } = x;
const msgs = rooms.history(room, { limit: 60 });
const msgs = rooms.history(room, { limit: 60 });
if (me && can.role) rooms.markRead(room, me);
if (me && can.role) rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
let composer;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
- else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">Join this room</button></form>`;
+ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>`;
+ else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
+ else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
+ else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
else composer = notice('error', 'You cannot post here right now.');
else composer = notice('error', 'You cannot post here right now.');
const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';
const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';
const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';
const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';
+ const YOU_ARE = { owner: 'the owner', mod: 'a moderator', speaker: 'a speaker', participant: 'a participant', member: 'a member', viewer: 'a viewer' };
+ const yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';
+ const isCall = room.kind === 'call';
+ const callReady = isCall && !!require('../config').calls.enabled;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true,
- description: room.topic || `${room.name}, a chat room on OpenVibe.`,
- page: { view: 'room', room: room.slug, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate },
+ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
+ description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
+ page: { view: 'room', room: room.slug, kind: room.kind, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
+ ...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: `<p><a href="/rooms">← Rooms</a></p>
body: `<p><a href="/rooms">← Rooms</a></p>
-<h1>${esc(room.name)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
-<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
+<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
+<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
${error}
${error}
-<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
+${isCall ? callPanel(room, can, me) : ''}
+<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}`,
${composer}${tools}`,
});
});
});
});
@@ -385,7 +475,7 @@ ${composer}${tools}`,
@@ -385,7 +475,7 @@ ${composer}${tools}`,
router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
- if (!room) return res.redirect(303, '/rooms');
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
const message = rooms.post(room, actor.user, req.body && req.body.message);
const message = rooms.post(room, actor.user, req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
@@ -395,9 +485,10 @@ ${composer}${tools}`,
@@ -395,9 +485,10 @@ ${composer}${tools}`,
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
- if (!room) return res.redirect(303, '/rooms');
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
rooms.join(room, actor.user);
rooms.join(room, actor.user);
+ applyRoomChange(room);
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
@@ -408,13 +499,14 @@ ${composer}${tools}`,
@@ -408,13 +499,14 @@ ${composer}${tools}`,
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
rooms.leave(room, actor.user);
rooms.leave(room, actor.user);
require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
+ applyRoomChange(room);
res.redirect(303, '/rooms');
res.redirect(303, '/rooms');
}));
}));
router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
- if (!room) return res.redirect(303, '/rooms');
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
@@ -427,8 +519,18 @@ ${composer}${tools}`,
@@ -427,8 +519,18 @@ ${composer}${tools}`,
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
- const members = rooms.members(room);
- const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>' : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${[...(can.manage ? ['mod'] : []), 'member', 'blocked', 'none'].map((r) => `<option value="${r}"${r === m.role ? ' selected' : ''}>${r === 'none' ? 'remove' : r}</option>`).join('')}</select> <button type="submit">Set</button></form>`;
+ const members = rooms.members(room, { seen: true });
+ const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
+ const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
+ const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
+ const seen = (m) => { const t = hhmm(m.last_seen_at); return t.iso ? ` <span class="oc-muted">· seen <time datetime="${t.iso}">${t.iso.slice(0, 10)}</time></span>` : ''; };
+ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>'
+ : (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'
+ : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
+ const addRole = rooms.joinRoleOf(room);
+ const attachments = can.manage ? rooms.attachments(room) : [];
+ const communityUrl = config.web.communityUrl;
+ const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
@@ -436,35 +538,55 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}
@@ -436,35 +538,55 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
+${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
<p><button type="submit">Save</button></p></form>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
<h2>Members</h2>
<h2>Members</h2>
-<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="member"> <button type="submit">Add</button></form>
-<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span> ${roleForm(m)}</li>`).join('')}</ul>`,
+<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
+<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
+<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span>${seen(m)} ${roleForm(m)}</li>`).join('')}</ul>
+${attachList}`,
});
});
});
});
+ function roleHelp(kind) {
+ if (kind === 'call') return 'Speakers, moderators and the owner talk in the call; participants listen and write in the chat; viewers listen and read.';
+ if (kind === 'system') return 'Only the owner and moderators post; everyone else reads (viewers).';
+ return 'Members write; viewers only read; moderators delete messages and manage members.';
+ }
+
router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
- if (!room) return res.redirect(303, '/rooms');
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
const b = req.body || {};
- rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds) });
+ rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });
+ applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
- if (!room) return res.redirect(303, '/rooms');
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
- const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || 'member'));
- if (role === 'blocked' || role === 'none') require('../chat/chat-server').removeFromRoom(room.id, target.id);
+ const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));
+ if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);
+ applyRoomChange(room);
+ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
+ }));
+
+ router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/settings`;
+ rooms.detach(room, actor.user, req.params.service, req.params.resource);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * openvibe.chat's DM inbox (roadmap WS-I task 8, server/web/pages.js): the inbox lists your conversations
+ * with unread counts (and the navigation counts unread messages and room messages); a conversation is
+ * for its participants only, and a guessed id looks exactly like one that does not exist; blocking the
+ * other person of a 1:1 conversation closes it both ways (neither can send, nor start a new one, and the
+ * user search hides them), the inbox lists who you blocked, and unblocking opens it again; groups have
+ * no one to block.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('dm inbox');
+let h, alice, bob, carol, conv;
+const SITE = 'https://openvibe.chat';
+const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
+const req = async (method, path, { headers = {}, raw } = {}) => {
+ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });
+ return { status: r.status, headers: r.headers, text: await r.text() };
+};
+const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
+const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });
+const navLabel = (page, key) => {
+ const cfg = JSON.parse(page.match(/window\.__OV_PAGE = (.*);\n/)[1]);
+ return cfg.navbar.links.find((l) => l.href === key).label;
+};
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ alice = h.addUser('alice', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ carol = h.addUser('carol', { subject: ids.newId('user') });
+ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+});
+
+t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {
+ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
+ conv = Number(/^\/messages\/(\d+)$/.exec(r.headers.get('location'))[1]);
+ await req('POST', `/messages/${conv}`, form(alice, { message: 'hi bob' }));
+ await req('POST', `/messages/${conv}`, form(alice, { message: 'are you there?' }));
+ let inbox = await req('GET', '/messages', { headers: cookie(bob) });
+ assert.strictEqual(inbox.status, 200);
+ assert.match(inbox.text, /<span class="oc-badge">2 new<\/span>/);
+ assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
+ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
+ assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
+ const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });
+ assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');
+ assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');
+ assert.ok(!(await dmApi('GET', '/conversations', carol)).body.conversations.some((c) => c.id === conv));
+
+ // Opening it reads it.
+ assert.strictEqual((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).status, 200);
+ inbox = await req('GET', '/messages', { headers: cookie(bob) });
+ assert.ok(!/\d+ new<\/span>/.test(inbox.text));
+ assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');
+ assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);
+
+ // Rooms count too.
+ await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });
+ await h.http('POST', '/api/chat/rooms/book-club/join', { token: bob.token, body: {} });
+ for (const m of ['chapter one', 'chapter two', 'chapter three']) await h.http('POST', '/api/chat/rooms/book-club/messages', { token: alice.token, body: { message: m } });
+ assert.strictEqual(navLabel((await req('GET', '/', { headers: cookie(bob) })).text, '/rooms'), 'Rooms (3)');
+});
+
+t('participants only: a guessed conversation looks like no conversation', async () => {
+ const theirs = await req('GET', `/messages/${conv}`, { headers: cookie(carol) });
+ const none = await req('GET', '/messages/999999', { headers: cookie(carol) });
+ assert.deepStrictEqual([theirs.status, none.status], [404, 404]);
+ const h1 = (x) => (x.text.match(/<h1>(.*?)<\/h1>/) || [])[1];
+ assert.strictEqual(h1(theirs), h1(none), 'the same page either way');
+ assert.ok(!theirs.text.includes('hi bob'));
+ assert.strictEqual((await dmApi('GET', `/conversations/${conv}/messages`, carol)).status, 403);
+ assert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);
+ const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));
+ assert.match(r.headers.get('location'), /error=/);
+ assert.ok(!h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv]).some((m) => m.message === 'let me in'));
+ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');
+});
+
+t('block: the conversation closes both ways; the inbox lists who you blocked; unblock opens it again', async () => {
+ assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(bob, {}, { origin: 'https://evil.example' }))).status, 403, 'forms from elsewhere are refused');
+ let page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
+ assert.match(page.text, /Block Alice<\/button>/);
+ let r = await req('POST', `/messages/${conv}/block`, form(bob, {}));
+ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
+
+ page = await req('GET', `/messages/${conv}`, { headers: cookie(bob) });
+ assert.match(page.text, /You blocked Alice/);
+ assert.match(page.text, /Unblock Alice<\/button>/);
+ assert.ok(!page.text.includes('id="oc-compose"'), 'no composer');
+ page = await req('GET', `/messages/${conv}`, { headers: cookie(alice) });
+ assert.match(page.text, /You cannot message this person\./);
+ assert.ok(!page.text.includes('id="oc-compose"'));
+ assert.ok(!page.text.includes('blocked'), 'the other side is not told who blocked whom');
+
+ r = await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'still there?' });
+ assert.strictEqual(r.status, 403, 'blocked people cannot send');
+ r = await req('POST', `/messages/${conv}`, form(alice, { message: 'still there?' }));
+ assert.match(r.headers.get('location'), /error=/);
+ r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
+ assert.match(r.headers.get('location'), /^\/messages\?error=/, 'nor start a new conversation');
+ assert.strictEqual((await dmApi('POST', '/conversations', bob, { user_ids: [alice.id] })).status, 403, 'the blocker cannot either');
+ assert.ok(!(await dmApi('GET', '/users/search?q=bo', alice)).body.users.some((u) => u.id === bob.id), 'the user search hides them');
+
+ const inbox = await req('GET', '/messages', { headers: cookie(bob) });
+ assert.match(inbox.text, /People you blocked \(1\)/);
+ assert.ok(inbox.text.includes(`action="/messages/unblock/${alice.id}"`));
+ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: `/messages/${conv}` }));
+ assert.strictEqual(r.headers.get('location'), `/messages/${conv}`);
+ r = await req('POST', `/messages/unblock/${alice.id}`, form(bob, { back: 'https://evil.example/' }));
+ assert.strictEqual(r.headers.get('location'), '/messages?unblocked=1', 'back goes to a conversation or the inbox, nowhere else');
+ assert.strictEqual((await dmApi('POST', `/conversations/${conv}/messages`, alice, { message: 'back again' })).status, 200);
+ assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /back again/);
+});
+
+t('groups have no one to block', async () => {
+ const r = await dmApi('POST', '/conversations', alice, { user_ids: [bob.id, carol.id], name: 'Trio' });
+ assert.strictEqual(r.status, 200, r.text);
+ const gid = r.body.conversation.id;
+ const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });
+ assert.ok(!/Block /.test(page.text));
+ assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));
+ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id]).n, 0);
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -130,7 +130,8 @@ async function boot({ env = {} } = {}) {
@@ -130,7 +130,8 @@ async function boot({ env = {} } = {}) {
effects: [], // { name, body }
effects: [], // { name, body }
mirror: [], // changes
mirror: [], // changes
mirrorStatus: 200,
mirrorStatus: 200,
- down: false,
+ down: false, // Live refuses: every connection is dropped at once
+ hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 1,
nextAnon: 1,
mediaState: { queue: [], now_playing: null },
mediaState: { queue: [], now_playing: null },
seenTokens: [],
seenTokens: [],
@@ -156,6 +157,7 @@ async function boot({ env = {} } = {}) {
@@ -156,6 +157,7 @@ async function boot({ env = {} } = {}) {
live.requests.push(p);
live.requests.push(p);
const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); };
const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); };
if (live.down) { req.socket.destroy(); return; }
if (live.down) { req.socket.destroy(); return; }
+ if (live.hang) { live.hung = (live.hung || 0) + 1; return; }
if (p.startsWith('/internal/chat-context/')) {
if (p.startsWith('/internal/chat-context/')) {
const bad = verify(req, 'live.chat_context.read');
const bad = verify(req, 'live.chat_context.read');
if (bad) return send(401, { error: bad });
if (bad) return send(401, { error: bad });
@@ -430,7 +432,7 @@ async function boot({ env = {} } = {}) {
@@ -430,7 +432,7 @@ async function boot({ env = {} } = {}) {
try { h.chatServer.close(); } catch { /* */ }
try { h.chatServer.close(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
await new Promise((r) => h.server.close(() => r()));
- network.close(); liveServer.close();
+ network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
try { h.db.close(); } catch { /* */ }
try { h.db.close(); } catch { /* */ }
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * openvibe.chat with OpenVibe.Live down (roadmap WS-I task 8): every page of the site renders and the core
+ * APIs work while Live refuses connections, and again while Live accepts them and never answers (a hung
+ * Live must not hold a page past the budget). People sign in with their Network token, resolved from
+ * the ctx_users projection, so nothing asks Live. Pages load nothing from openvibe.live (profile links
+ * are the only Live URLs, and they are links).
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { boot, suite, b64url } = require('./helpers');
+
+const t = suite('live down');
+let h, alice, bob, carol, aliceJwt, bobJwt, carolJwt, convId;
+const SITE = 'https://openvibe.chat';
+const now = () => Math.floor(Date.now() / 1000);
+const jwt = (u) => {
+ const head = `${b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64url(JSON.stringify({ iss: h.ISS, sub: String(u.id + 7000), subject_id: u.subject_id, username: u.username, role: u.role, iat: now() - 5, exp: now() + 3600 }))}`;
+ return `${head}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(head), h.keys.privateKey))}`;
+};
+const req = async (method, path, { token, headers = {}, raw, json } = {}) => {
+ const started = Date.now();
+ const r = await fetch(`${h.base}${path}`, {
+ method, redirect: 'manual',
+ headers: { ...(token ? { cookie: `ov_token=${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json', authorization: `Bearer ${token}` } : {}), ...headers },
+ body: json !== undefined ? JSON.stringify(json) : raw,
+ });
+ const text = await r.text();
+ let body = null; try { body = JSON.parse(text); } catch { /* html */ }
+ return { status: r.status, headers: r.headers, text, body, ms: Date.now() - started };
+};
+const form = (token, fields) => ({ token, headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: new URLSearchParams(fields).toString() });
+const html = { accept: 'text/html' };
+
+/** No page may load anything from Live: scripts, styles, images, frames, form targets, fetches. */
+function assertNoLiveResources(page, path) {
+ for (const re of [/<(script|img|iframe|audio|video|source)[^>]+src="https?:\/\/(www\.)?openvibe\.live/i, /<link[^>]+href="https?:\/\/(www\.)?openvibe\.live/i, /<form[^>]+action="https?:\/\/(www\.)?openvibe\.live/i]) {
+ assert.ok(!re.test(page), `${path} loads something from openvibe.live (${re})`);
+ }
+}
+
+t('boot, and some of everything while Live is up', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ alice = h.addUser('alice', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ carol = h.addUser('carol', { subject: ids.newId('user') });
+ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+ [aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);
+ let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });
+ assert.strictEqual(r.status, 200, r.text);
+ convId = r.body.conversation.id;
+ await req('POST', `/api/dm/conversations/${convId}/messages`, { token: aliceJwt, json: { message: 'before the outage' } });
+ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Night Shift', topic: 'while Live sleeps' } })).status, 201);
+ assert.strictEqual((await req('POST', '/api/chat/rooms', { token: aliceJwt, json: { name: 'Quiet Corner', visibility: 'private' } })).status, 201);
+ assert.strictEqual((await req('POST', '/api/chat/send', { token: aliceJwt, json: { message: 'global before the outage' } })).status, 200);
+});
+
+async function everyPageAndApi(label, budgetMs) {
+ const pages = [
+ ['/', null], ['/', aliceJwt], ['/rooms', null], ['/rooms', aliceJwt], ['/r/night-shift', null], ['/r/night-shift', bobJwt],
+ ['/r/quiet-corner', aliceJwt], ['/r/night-shift/settings', aliceJwt], ['/messages', aliceJwt], [`/messages/${convId}`, bobJwt],
+ ['/settings', aliceJwt], ['/updates', null], ['/robots.txt', null], ['/sitemap.xml', null], ['/auth/me', aliceJwt],
+ ];
+ for (const [path, token] of pages) {
+ const r = await req('GET', path, { token, headers: html });
+ assert.strictEqual(r.status, 200, `${label}: GET ${path} → ${r.status}`);
+ assert.ok(r.ms < budgetMs, `${label}: GET ${path} took ${r.ms} ms`);
+ assertNoLiveResources(r.text, path);
+ }
+ assert.match((await req('GET', '/', { token: aliceJwt })).text, /id="oc-compose"/, `${label}: signed in from the Network token alone`);
+ assert.match((await req('GET', `/messages/${convId}`, { token: bobJwt })).text, /before the outage/);
+ assert.strictEqual((await req('GET', '/r/quiet-corner', { token: bobJwt })).status, 404, `${label}: private rooms stay private`);
+ assert.strictEqual((await req('GET', `/messages/${convId}`, { token: carolJwt })).status, 404, `${label}: so do conversations`);
+ assert.strictEqual((await req('GET', '/no-such-page', { headers: html })).status, 404);
+
+ // The forms, without JavaScript.
+ const posts = [
+ ['/send', aliceJwt, { message: `global during ${label}` }, /^\/#oc-compose$/],
+ ['/r/night-shift', aliceJwt, { message: `room during ${label}` }, /^\/r\/night-shift#oc-compose$/],
+ [`/messages/${convId}`, bobJwt, { message: `dm during ${label}` }, new RegExp(`^/messages/${convId}#oc-compose$`)],
+ ['/messages/new', carolJwt, { username: 'alice' }, /^\/messages\/\d+$/],
+ ];
+ for (const [path, token, fields, want] of posts) {
+ const r = await req('POST', path, form(token, fields));
+ assert.strictEqual(r.status, 303, `${label}: POST ${path} → ${r.status}`);
+ assert.match(r.headers.get('location'), want, `${label}: POST ${path} → ${r.headers.get('location')}`);
+ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);
+ }
+ const room = (await req('GET', '/r/night-shift')).text;
+ assert.ok(room.includes(`room during ${label}`));
+ assert.ok((await req('GET', '/')).text.includes(`global during ${label}`));
+
+ // The APIs the site's script and apps use.
+ const apis = [
+ ['GET', '/api/chat/rooms', aliceJwt], ['GET', '/api/chat/rooms/night-shift/messages', null], ['GET', '/api/chat/global/history?limit=20', null],
+ ['GET', '/api/dm/conversations', aliceJwt], ['GET', `/api/dm/conversations/${convId}/messages`, aliceJwt], ['GET', '/api/dm/unread', bobJwt],
+ ['GET', '/api/chat/ice-servers', null],
+ ];
+ for (const [method, path, token] of apis) {
+ const r = await req(method, path, { headers: token ? { authorization: `Bearer ${token}` } : {} });
+ assert.strictEqual(r.status, 200, `${label}: ${method} ${path} → ${r.status} ${r.text.slice(0, 120)}`);
+ assert.ok(r.ms < budgetMs, `${label}: ${method} ${path} took ${r.ms} ms`);
+ }
+ for (const [path, token, json] of [
+ ['/api/chat/rooms/night-shift/messages', bobJwt, null],
+ [`/api/dm/conversations/${convId}/messages`, aliceJwt, { message: `api dm during ${label}` }],
+ ['/api/chat/send', bobJwt, { message: `api global during ${label}` }],
+ ]) {
+ if (!json) await req('POST', '/api/chat/rooms/night-shift/join', { token, json: {} });
+ const r = await req('POST', path, { token, json: json || { message: `api room during ${label}` } });
+ assert.ok(r.status === 200 || r.status === 201, `${label}: POST ${path} → ${r.status} ${r.text.slice(0, 160)}`);
+ assert.ok(r.ms < budgetMs, `${label}: POST ${path} took ${r.ms} ms`);
+ }
+ const hist = await req('GET', '/api/chat/global/history?limit=50');
+ assert.ok(hist.body.messages.some((m) => m.message === `api global during ${label}`));
+
+ // The live feed: a signed-in socket (the browser's cookie, as on openvibe.chat) joins global chat and a
+ // room and posts there. Joining warms Live's caches for at most live-context's WARM_TIMEOUT_MS (2.5 s)
+ // per step, so a hung Live delays the feed by seconds; messages sent meanwhile wait, in order.
+ const socketBudget = label === 'hung' ? 9000 : budgetMs;
+ const ws = await h.ws({ origin: 'https://openvibe.live', bearer: aliceJwt });
+ ws.sendJson({ type: 'join' });
+ const auth = await ws.next((m) => m.type === 'auth', socketBudget);
+ assert.deepStrictEqual([auth.authenticated, auth.core_username], [true, 'alice'], `${label}: the socket signs in from the Network token`);
+ ws.sendJson({ type: 'join_room', room: 'night-shift' });
+ await ws.next((m) => m.type === 'room_joined', socketBudget);
+ ws.sendJson({ type: 'room_message', message: `socket during ${label}` });
+ await ws.next((m) => m.type === 'room_message' && m.message.message === `socket during ${label}`, socketBudget);
+ ws.close();
+}
+
+t('Live refuses connections: every page renders, the forms and the core APIs work', async () => {
+ h.live.down = true;
+ const before = h.live.requests.length;
+ await everyPageAndApi('refused', 2500);
+ h.live.down = false;
+ assert.ok(h.live.requests.length >= before, 'Live was down the whole time');
+});
+
+t('Live hangs (accepts, never answers): nothing waits on it past the budget', async () => {
+ h.live.hang = true;
+ await everyPageAndApi('hung', 3000);
+ assert.ok(h.live.hung > 0, 'Chat did ask the hung Live (and did not wait for it)');
+ h.live.hang = false;
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,356 @@
@@ -0,0 +1,356 @@
+'use strict';
+/**
+ * Room kinds and roles (WS-I task 4, server/rooms/rooms.js): community, call and system rooms; the
+ * roles owner, mod, speaker, participant, member, viewer and blocked with what each may do per kind
+ * (the whole matrix, public and private, signed out, staff, a site ban); roles belong to their kind and
+ * mods are the owner's; joining gives the kind's role. Call rooms (server/calls/call-server.js): the
+ * room decides who is in the call (private rooms look missing, blocked people are refused) and who
+ * talks (listeners are force-muted and cannot unmute); a role change reaches a running call at once;
+ * a ban in the call blocks the person in the room; room calls stay out of the voice-channel list.
+ * Attachments: a room's manager attaches it to a Community space with their own token, idempotently,
+ * and detaches it; nobody else can, and a private room stays a 404 to outsiders.
+ */
+const assert = require('assert');
+const WebSocket = require('ws');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('room kinds');
+let h, rooms;
+let own, md, spk, par, mem, vw, blk, out, staff, banned, bot;
+const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
+
+function callWs({ channelId, token = null, ip = '203.0.113.30' } = {}) {
+ return new Promise((resolve, reject) => {
+ const qs = new URLSearchParams({ channelId });
+ if (token) qs.set('token', token);
+ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?${qs}`, { headers: { 'cf-connecting-ip': ip, origin: 'https://openvibe.live' } });
+ const all = [];
+ const waiters = [];
+ ws.on('message', (d) => {
+ let m; try { m = JSON.parse(d.toString()); } catch { return; }
+ all.push(m);
+ for (const w of [...waiters]) if (w.pred(m)) { waiters.splice(waiters.indexOf(w), 1); clearTimeout(w.timer); w.resolve(m); }
+ });
+ ws.all = all;
+ ws.next = (pred, ms = 3000) => {
+ const hit = all.find((m) => pred(m) && !m.__taken);
+ if (hit) { hit.__taken = true; return Promise.resolve(hit); }
+ return new Promise((res, rej) => {
+ const w = { pred: (m) => { if (pred(m)) { m.__taken = true; return true; } return false; }, resolve: res };
+ w.timer = setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); rej(new Error(`timed out waiting (got: ${JSON.stringify(all.map((x) => x.type).slice(-12))})`)); }, ms);
+ waiters.push(w);
+ });
+ };
+ ws.none = async (pred, ms = 300) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };
+ ws.sendJson = (o) => ws.send(JSON.stringify(o));
+ ws.closed = new Promise((r) => ws.on('close', () => r(true)));
+ ws.on('open', () => resolve(ws));
+ ws.on('error', reject);
+ });
+}
+async function joinCall(opts) {
+ const ws = await callWs(opts);
+ const m = await ws.next((x) => x.type === 'welcome' || x.type === 'error');
+ ws.welcome = m.type === 'welcome' ? m : null;
+ ws.error = m.type === 'error' ? m.message : null;
+ return ws;
+}
+const bye = async (...sockets) => { for (const ws of sockets) if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } await h.sleep(30); };
+
+t('boot (CHAT_CALLS=1)', async () => {
+ h = await boot({ env: { CHAT_CALLS: '1' } });
+ rooms = require('../server/rooms/rooms');
+ const mk = (name, extra = {}) => h.addUser(name, { subject: ids.newId('user'), ...extra });
+ own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');
+ blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');
+ bot = h.addUser('bot1', { apiScopes: ['chat'] });
+ for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('the matrix: what every role may do in every kind, public and private', async () => {
+ // read, post, call join, talk, moderate, manage
+ const EXPECT = {
+ community: { owner: 'rp--mM', mod: 'rp--m-', member: 'rp----', viewer: 'r-----', blocked: '------' },
+ call: { owner: 'rpjtmM', mod: 'rpjtm-', speaker: 'rpjt--', participant: 'rpj---', viewer: 'r-j---', blocked: '------' },
+ system: { owner: 'rp--mM', mod: 'rp--m-', viewer: 'r-----', blocked: '------' },
+ };
+ const byRole = { mod: md, speaker: spk, participant: par, member: mem, viewer: vw, blocked: blk };
+ const flags = (a) => ['read', 'post', 'join', 'talk', 'moderate', 'manage'].map((k, i) => (a[k] ? 'rpjtmM'[i] : '-')).join('');
+ for (const kind of ['community', 'call', 'system']) {
+ for (const visibility of ['public', 'private']) {
+ const owner = kind === 'system' ? staff : own;
+ const room = rooms.bySlug(rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility }).slug);
+ assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);
+ for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') rooms.setRole(room, owner, byRole[role].id, role);
+ for (const [role, want] of Object.entries(EXPECT[kind])) {
+ const who = role === 'owner' ? owner : byRole[role];
+ const a = rooms.access(room, who);
+ assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);
+ // A system room's owner is chat staff: they may post and manage anyway.
+ assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);
+ }
+ const pub = visibility === 'public';
+ const listen = kind === 'call' && pub ? 'j' : '-';
+ assert.strictEqual(flags(rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);
+ assert.strictEqual(flags(rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);
+ if (kind !== 'system') assert.strictEqual(flags(rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);
+ else assert.strictEqual(flags(rooms.access(room, staff)), 'rp--mM', 'system: staff post');
+ }
+ }
+ // A site ban: reading stays, posting and talking go, whatever the role.
+ const room = rooms.bySlug(rooms.create(own, { name: 'Banned Test', kind: 'call' }).slug);
+ rooms.setRole(room, own, banned.id, 'speaker');
+ assert.strictEqual(flags(rooms.access(room, banned)), 'rpjt--');
+ h.live.addBan({ user_id: banned.id });
+ await h.ctx.invalidateBans();
+ assert.strictEqual(flags(rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');
+ h.live.clearBans();
+ await h.ctx.invalidateBans();
+});
+
+t('roles belong to their kind, mods are the owner\'s, and joining gives the kind\'s role', async () => {
+ let r = await api('POST', '/', own, { name: 'Open Stage', kind: 'call' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.body.room.kind, r.body.room.join_role, r.body.room.call_channel], ['call', 'participant', 'room-open-stage']);
+ assert.deepStrictEqual([(await api('POST', '/', own, { name: 'My News', kind: 'system' })).status, (await api('POST', '/', own, { name: 'My News', kind: 'system' })).body.code], [403, 'rooms.kind'], 'people do not make system rooms');
+ assert.strictEqual((await api('POST', '/', staff, { name: 'OpenVibe News', kind: 'system' })).status, 201);
+ assert.strictEqual((await api('POST', '/', own, { name: 'Odd Join', join_role: 'speaker' })).body.code, 'rooms.join_role', 'join_role is for call rooms');
+ assert.strictEqual((await api('POST', '/', own, { name: 'Tea Talk' })).status, 201);
+
+ r = await api('POST', '/tea-talk/members', own, { username: 'speaker1', role: 'speaker' });
+ assert.deepStrictEqual([r.status, r.body.code], [422, 'rooms.role_kind'], 'no speakers in a community room');
+ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'member1', role: 'member' })).body.code, 'rooms.role_kind', 'no members in a call room');
+ assert.strictEqual((await api('POST', '/openvibe-news/members', staff, { username: 'part1', role: 'participant' })).body.code, 'rooms.role_kind');
+
+ // Joining: community → member, call → the room's join role, system → viewer.
+ assert.strictEqual((await api('POST', '/tea-talk/join', mem)).body.role, 'member');
+ assert.strictEqual((await api('POST', '/open-stage/join', par)).body.role, 'participant');
+ assert.strictEqual((await api('POST', '/openvibe-news/join', out)).body.role, 'viewer');
+ r = await api('PATCH', '/open-stage', own, { join_role: 'speaker' });
+ assert.strictEqual(r.body.room.join_role, 'speaker');
+ assert.strictEqual((await api('POST', '/open-stage/join', spk)).body.role, 'speaker');
+ assert.strictEqual((await api('PATCH', '/tea-talk', own, { join_role: 'speaker' })).body.code, 'rooms.join_role');
+ await api('PATCH', '/open-stage', own, { join_role: 'participant' });
+
+ // Mods: the owner appoints them; a mod sets the other roles but not mods.
+ assert.strictEqual((await api('POST', '/open-stage/members', own, { username: 'mod1', role: 'mod' })).body.role, 'mod');
+ r = await api('POST', '/open-stage/members', md, { username: 'part1', role: 'speaker' });
+ assert.deepStrictEqual([r.status, r.body.role], [200, 'speaker'], 'a mod makes speakers');
+ assert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');
+ assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');
+ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });
+ const log = h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id").map((x) => x.action_type);
+ assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);
+
+ // Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.
+ assert.strictEqual((await api('POST', '/openvibe-news/messages', out, { message: 'can I?' })).body.code, 'rooms.read_only');
+ assert.strictEqual((await api('POST', '/openvibe-news/messages', staff, { message: 'Maintenance tonight at 22:00 UTC.' })).status, 201);
+ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'viewer' });
+ assert.strictEqual((await api('POST', '/tea-talk/messages', vw, { message: 'hello' })).body.code, 'rooms.read_only');
+ assert.strictEqual((await api('POST', '/open-stage/messages', par, { message: 'participants write' })).status, 201);
+ assert.strictEqual((await api('POST', '/open-stage/join', vw)).status, 200);
+ await api('POST', '/open-stage/members', own, { username: 'viewer1', role: 'viewer' });
+ assert.strictEqual((await api('POST', '/open-stage/messages', vw, { message: 'viewers do not' })).body.code, 'rooms.read_only');
+
+ // What the room page reads: the caller's powers and the call.
+ r = await api('GET', '/open-stage', par);
+ assert.deepStrictEqual(r.body.can, { read: true, post: true, join: true, talk: false, moderate: false, manage: false });
+ assert.deepStrictEqual([r.body.call.enabled, r.body.call.channel, r.body.call.participants], [true, 'room-open-stage', 0]);
+ assert.strictEqual((await api('GET', '/tea-talk', null)).body.call, undefined, 'community rooms have no call');
+
+ // last_seen_at: moderators see it, others do not.
+ const seenMod = (await api('GET', '/open-stage/members', own)).body.members.find((m) => m.username === 'part1');
+ assert.ok(seenMod.last_seen_at, 'posting counts as seen');
+ assert.ok(!('last_seen_at' in (await api('GET', '/open-stage/members', par)).body.members[0]), 'presence is for moderators');
+});
+
+t('call rooms: the room decides who is in the call and who talks', async () => {
+ const ownWs = await joinCall({ channelId: 'room-open-stage', token: own.token });
+ assert.ok(ownWs.welcome, ownWs.error);
+ assert.deepStrictEqual([ownWs.welcome.room, ownWs.welcome.roomRole, ownWs.welcome.canTalk, ownWs.welcome.canModerate], ['open-stage', 'owner', true, true]);
+
+ const parWs = await joinCall({ channelId: 'room-open-stage', token: par.token, ip: '203.0.113.31' });
+ assert.deepStrictEqual([parWs.welcome.roomRole, parWs.welcome.canTalk, parWs.welcome.canModerate], ['participant', false, false]);
+ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);
+ const joined = await ownWs.next((m) => m.type === 'peer-joined');
+ assert.deepStrictEqual([joined.roomRole, joined.canTalk, joined.forceMuted], ['participant', false, true], 'everyone sees who may talk');
+
+ // Signalling works both ways; a listener's own unmute is ignored.
+ parWs.sendJson({ type: 'offer', targetPeerId: ownWs.welcome.peerId, sdp: { type: 'offer', sdp: 'v=0 listener' } });
+ assert.strictEqual((await ownWs.next((m) => m.type === 'offer')).sdp.sdp, 'v=0 listener');
+ parWs.sendJson({ type: 'mute', muted: false });
+ assert.ok(await ownWs.none((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId), 'a listener stays muted');
+
+ // Anyone may listen in a public call room; a moderator cannot lift a listener's mute (make them a speaker instead).
+ const anonWs = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.32' });
+ assert.deepStrictEqual([anonWs.welcome.canTalk, anonWs.welcome.roomRole], [false, null]);
+ ownWs.sendJson({ type: 'force-mute', targetPeerId: anonWs.welcome.peerId, forceMuted: false });
+ assert.ok(await anonWs.none((m) => m.type === 'force-muted' && m.forceMuted === false));
+
+ // Room calls are not in Live's voice-channel list, but the room's own page shows them.
+ const list = await h.http('GET', '/api/streams/voice-channels');
+ assert.ok(!JSON.stringify(list.body).includes('room-open-stage'), 'not in the voice sidebar');
+ assert.strictEqual((await api('GET', '/open-stage', null)).body.call.participants, 3);
+
+ // Private call rooms look missing to outsiders; invited people get in; blocked people never do.
+ await api('POST', '/', own, { name: 'Back Stage', kind: 'call', visibility: 'private' });
+ let ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
+ assert.strictEqual(ws.error, 'Voice channel not found');
+ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'participant' });
+ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
+ assert.ok(ws.welcome, 'invited');
+ await bye(ws);
+ await api('POST', '/back-stage/members', own, { username: 'outsider1', role: 'blocked' });
+ ws = await joinCall({ channelId: 'room-back-stage', token: out.token, ip: '203.0.113.33' });
+ assert.strictEqual(ws.error, 'Voice channel not found');
+ ws = await joinCall({ channelId: 'room-tea-talk', token: own.token, ip: '203.0.113.34' });
+ assert.strictEqual(ws.error, 'Voice channel not found', 'only call rooms have a call');
+ await bye(anonWs);
+ h._stage = { ownWs, parWs };
+});
+
+t('a role change reaches the running call at once; a ban in the call blocks in the room', async () => {
+ const { ownWs, parWs } = h._stage;
+ let r = await api('POST', '/open-stage/members', own, { username: 'part1', role: 'speaker' });
+ assert.strictEqual(r.body.role, 'speaker');
+ const role = await parWs.next((m) => m.type === 'room-role');
+ assert.deepStrictEqual([role.role, role.canTalk], ['speaker', true]);
+ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === false);
+ const upd = await ownWs.next((m) => m.type === 'peer-updated' && m.peerId === parWs.welcome.peerId);
+ assert.deepStrictEqual([upd.roomRole, upd.canTalk], ['speaker', true]);
+ parWs.sendJson({ type: 'mute', muted: false });
+ assert.strictEqual((await ownWs.next((m) => m.type === 'peer-muted' && m.peerId === parWs.welcome.peerId)).muted, false, 'a speaker unmutes');
+
+ await api('POST', '/open-stage/members', md, { username: 'part1', role: 'viewer' });
+ assert.strictEqual((await parWs.next((m) => m.type === 'room-role')).canTalk, false, 'demoted: listening again');
+ await parWs.next((m) => m.type === 'force-muted' && m.forceMuted === true);
+
+ await api('POST', '/open-stage/members', own, { username: 'part1', role: 'blocked' });
+ await parWs.closed;
+ assert.ok(parWs.all.some((m) => m.type === 'error'), 'told before being dropped');
+ await ownWs.next((m) => m.type === 'peer-left' && m.peerId === parWs.welcome.peerId);
+
+ // A ban from inside the call is the room's block.
+ await api('POST', '/open-stage/members', own, { username: 'speaker1', role: 'speaker' });
+ const spkWs = await joinCall({ channelId: 'room-open-stage', token: spk.token, ip: '203.0.113.35' });
+ assert.strictEqual(spkWs.welcome.canTalk, true);
+ ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });
+ await spkWs.closed;
+ const room = rooms.bySlug('open-stage');
+ assert.strictEqual(rooms.access(room, spk).role, 'blocked');
+ assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');
+
+ // Turning the room private drops people who are not members (a guest listening).
+ const guest = await joinCall({ channelId: 'room-open-stage', ip: '203.0.113.36' });
+ assert.ok(guest.welcome);
+ r = await api('PATCH', '/open-stage', own, { visibility: 'private' });
+ assert.strictEqual(r.status, 200, r.text);
+ await guest.closed;
+ assert.ok(ownWs.readyState === WebSocket.OPEN, 'the owner stays');
+ await bye(ownWs);
+});
+
+t('attachments: a manager attaches with their own token, idempotently; detach; nobody else can', async () => {
+ let r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);
+ r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
+ assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');
+ assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'").n, 1);
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');
+ assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'discord', resource: 'x' })).body.code, 'rooms.attach_service');
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: '../evil' })).body.code, 'rooms.attach_resource');
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', null, { service: 'community', resource: 'help' })).status, 401);
+ r = await api('GET', '/tea-talk/attachments', own);
+ assert.deepStrictEqual(r.body.attachments.map((a) => [a.service, a.resource, a.title]), [['community', 'general', 'General']]);
+ assert.strictEqual((await api('GET', '/tea-talk/attachments', mem)).status, 403);
+ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/general', mem)).status, 403, 'nor detach');
+ r = await api('DELETE', '/tea-talk/attachments/community/general', own);
+ assert.deepStrictEqual([r.status, r.body.removed], [200, true]);
+ r = await api('DELETE', '/tea-talk/attachments/community/general', own);
+ assert.deepStrictEqual([r.status, r.body.removed], [200, false], 'detaching twice is fine');
+ // Chat staff manage every room, so they may attach one too; whoever attached may detach.
+ assert.strictEqual((await api('POST', '/tea-talk/attachments', staff, { service: 'community', resource: 'help' })).status, 201);
+ assert.strictEqual((await api('DELETE', '/tea-talk/attachments/community/help', staff)).body.removed, true);
+});
+
+t('the site: kinds on the rooms page, the call panel, roles and attachments in settings', async () => {
+ const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };
+ let r = await page('/rooms', own);
+ assert.match(r.text, /name="kind" value="call"/);
+ assert.ok(!r.text.includes('value="system"'), 'people are not offered system rooms');
+ assert.match((await page('/rooms', staff)).text, /name="kind" value="system"/);
+ assert.match((await page('/rooms', null)).text, /<span class="oc-badge oc-kind-system">announcements<\/span>/);
+
+ r = await page('/r/open-stage', own);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<span class="oc-badge oc-kind-call">call<\/span>/);
+ assert.match(r.text, /id="oc-call-join" hidden>Join the call</);
+ assert.match(r.text, /\/web\/call\.js\?v=/);
+ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
+ assert.deepStrictEqual(cfg.chat.call, { channel: 'room-open-stage', join: true, talk: true });
+ assert.strictEqual((await page('/r/open-stage', out)).status, 404, 'private now: not there for outsiders');
+ await api('POST', '/open-stage/members', own, { username: 'member1', role: 'participant' });
+ r = await page('/r/open-stage', mem);
+ assert.match(r.text, /id="oc-call-join" hidden>Listen in</);
+ assert.match(r.text, /you are a participant/);
+
+ r = await page('/r/open-stage/settings', own);
+ for (const role of ['moderator', 'speaker', 'participant', 'viewer', 'blocked']) assert.ok(r.text.includes(`>${role}</option>`), role);
+ assert.ok(!r.text.includes('>member</option>'), 'call rooms have no members, they have participants');
+ assert.match(r.text, /name="join_role"/);
+ assert.match(r.text, /Speakers, moderators and the owner talk in the call/);
+ assert.match(r.text, /· seen <time/, 'moderators see when people were last here');
+ await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General <b>' });
+ r = await page('/r/tea-talk/settings', own);
+ assert.match(r.text, /<a href="https:\/\/openvibe\.community\/s\/general">General <b><\/a>/);
+ assert.match(r.text, /action="\/r\/tea-talk\/attachments\/community\/general\/detach"/);
+ assert.ok(!(await page('/r/tea-talk', own)).text.includes('openvibe.community/s/general'), 'the room page itself makes no claim about the space');
+
+ r = await page('/r/openvibe-news', out);
+ assert.match(r.text, /Announcements from OpenVibe staff/);
+ assert.ok(!r.text.includes('id="oc-compose"'));
+ assert.match((await page('/r/openvibe-news', staff)).text, /placeholder="Post an announcement"/);
+});
+
+t('chat sockets following a room learn their new role at once, or leave', async () => {
+ const ws = await h.ws({ token: vw.token });
+ ws.sendJson({ type: 'join_room', room: 'tea-talk' });
+ const joined = await ws.next((m) => m.type === 'room_joined');
+ assert.deepStrictEqual([joined.kind, joined.role, joined.can.post], ['community', 'viewer', false]);
+ ws.sendJson({ type: 'room_message', message: 'viewers cannot' });
+ assert.strictEqual((await ws.next((m) => m.type === 'room_error')).code, 'rooms.read_only');
+ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'member' });
+ const acc = await ws.next((m) => m.type === 'room_access');
+ assert.deepStrictEqual([acc.role, acc.can.post], ['member', true]);
+ ws.sendJson({ type: 'room_message', message: 'members can' });
+ await ws.next((m) => m.type === 'room_message' && m.message.message === 'members can');
+ await api('PATCH', '/tea-talk', own, { visibility: 'private' });
+ assert.strictEqual((await ws.next((m) => m.type === 'room_access')).role, 'member', 'private now, but a member: still reading');
+ await api('POST', '/tea-talk/members', own, { username: 'viewer1', role: 'none' });
+ await ws.next((m) => m.type === 'room_left');
+ ws.close();
+});
+
+t('ICE servers for calls: STUN, and TURN only with credentials (short-lived with TURN_AUTH_SECRET)', async () => {
+ const crypto = require('crypto');
+ const turn = require('../server/net/turn');
+ assert.deepStrictEqual((await h.http('GET', '/api/chat/ice-servers')).body.iceServers, [{ urls: 'stun:stun.l.google.com:19302' }], 'no TURN configured: STUN only');
+ const at = Date.UTC(2026, 8, 26, 12);
+ const eph = turn.iceServers('u7', turn.turnConfig({ TURN_URL: 'turn:turn.example.org:3478', TURN_AUTH_SECRET: 's3cret' }), at);
+ const user = `${Math.floor(at / 1000) + turn.TTL_SECONDS}:u7`;
+ assert.deepStrictEqual(eph.iceServers.slice(1), [
+ { urls: 'turn:turn.example.org:3478', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },
+ { urls: 'turn:turn.example.org:3478?transport=tcp', username: user, credential: crypto.createHmac('sha1', 's3cret').update(user).digest('base64') },
+ ]);
+ assert.strictEqual(eph.ephemeral, true);
+ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn://turn.example.org' })).iceServers.length, 1, 'a turn: URL without credentials is left out');
+ assert.strictEqual(turn.iceServers('anon', turn.turnConfig({ TURN_URL: 'turn:turn.example.org', TURN_USERNAME: 'u', TURN_CREDENTIAL: 'p' })).iceServers[1].username, 'u');
+ assert.strictEqual(turn.normalizeTurnUrl('turn:user:[email protected]'), '', 'no credentials inside the URL');
+ assert.strictEqual(turn.normalizeTurnUrl('https://turn.example.org'), '');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -53,7 +53,9 @@ t('private rooms: invisible to outsiders, members by invitation', async () => {
@@ -53,7 +53,9 @@ t('private rooms: invisible to outsiders, members by invitation', async () => {
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);
assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);
assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);
assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);
- assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 403);
+ assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 404, 'joining says no more than a missing room would');
+ assert.strictEqual((await api('POST', '/no-such-room/join', bob)).status, 404);
+ assert.strictEqual((await api('POST', '/secret-club/messages', bob, { message: 'hi' })).status, 404);
assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');
assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');
assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');
assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');
r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });
r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });