openvibers/openvibe.chat Commits
Agent Streams S7: watch parties with their sync and a count without names (#367)
Changes
nnew file mode 100644@@@ -0,0 +1,33 @@++-- phase: expand++-- Agent Streams S7 (design 2026-11-06, AGENT-STREAMS-S7-REACH-DESIGN): watch parties on a public agent stream or its++-- VOD.++--++-- A party is a Chat room of kind 'party' (the room row keeps its name, members and messages) plus this row: the++-- stream it watches, the host whose position a VOD follows, and whether the stream's page may count it. A live party++-- follows the live edge and has no position. The room is private by default: its link carries a secret (only its++-- sha256 is stored), so joining is by that link and a stranger without it sees the same 404 as a missing room.++-- `listed` only lets the stream's page say "3 parties watching": the count query never reads a member or a name.++--++-- Expand only: 'party' joins the existing room kinds, every table is new, and an older release ignores all of it.++DO $$ BEGIN++ ALTER TABLE rooms DROP CONSTRAINT IF EXISTS rooms_kind_allowed;++ ALTER TABLE rooms ADD CONSTRAINT rooms_kind_allowed CHECK (kind IN ('community', 'call', 'system', 'agent_stream', 'party'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++++CREATE TABLE IF NOT EXISTS room_parties (++ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,++ agent_stream_id text COLLATE "C" NOT NULL,++ vod_id bigint,++ host_user_id bigint NOT NULL,++ host_subject text COLLATE "C" NOT NULL,++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('live', 'vod')),++ position_s integer NOT NULL DEFAULT 0 CHECK (position_s >= 0),++ playing boolean NOT NULL DEFAULT false,++ position_at timestamptz NOT NULL DEFAULT now(),++ listed boolean NOT NULL DEFAULT false,++ join_secret_hash text COLLATE "C" NOT NULL,++ ended_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS room_parties_stream ON room_parties (agent_stream_id) WHERE ended_at IS NULL;++CREATE INDEX IF NOT EXISTS room_parties_host ON room_parties (host_user_id);@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0",@@@ -1557,9 +1557,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.304.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",-- "integrity": "sha512-nUNV/kfgfYwBTqPPfa3u/rJAs0YMQXXMXqwRGlf4IJHGeUXbSo6/h12GtX5QwG9v3eqQYkc5a+nAQXwYWu7lDQ==",++ "version": "0.305.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",++ "integrity": "sha512-jTU1zlnTmHlydwL3/c5HVdUiPpd8r2KucVdHYUYX5MZgJoqy4M7Hg2jy6UKbJQ6s665uIZyTvswbmJjB2uwc5Q==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0",@@@ -305,6 +305,15 @@ async function ttsOverride(req) {};}++/** S7: how many listed watch parties a public agent stream has. A count only: no host, member or name. */++async function partyCount(req) {++ const q = query(req, { agent_stream: 40 });++ const streamId = String(q.agent_stream || '');++ if (!/^ags_[0-9A-HJKMNP-TV-Z]{26}$/.test(streamId)) bad('Invalid agent_stream');++ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [streamId]);++ return { ok: true, count: Number(row && row.n) || 0 };++}++// ── N5: channel sounds ──function soundRow(r) {return {@@@ -371,6 +380,7 @@ router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser));router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));++router.get('/parties/count', serviceAuth.guard('chat.messages.read'), route('party-count', partyCount));router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++/**++ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a Chat room of kind 'party' where friends watch a public++ * agent stream or its VOD in sync, with their own chat beside the stream's.++ *++ * - Sync: on a VOD the position follows the host's; on a live stream it follows the live edge (no position).++ * - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger++ * without it sees the same 404 as a missing room.++ * - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets++ * the stream's page count it ("3 parties watching"). The count query never reads a member or a name.++ *++ * The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.++ */++const crypto = require('crypto');++const db = require('../db/database');++const rooms = require('./rooms');++++const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;++const VOD_RE = /^\d{1,16}$/;++const MAX_TITLE = 200;++const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');++++class PartyError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++const fail = (status, code, message) => { throw new PartyError(status, code, message); };++const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');++const secret = () => crypto.randomBytes(18).toString('base64url').slice(0, 24);++++function titleOf(value) {++ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';++ if (!text || Array.from(text).length > MAX_TITLE) fail(422, 'party.title', 'A party needs a title of 1–200 characters');++ return text;++}++++/** A VOD's id: Media's numeric id, as Live and the Rundown carry it. */++function vodOf(value) {++ if (value == null || value === '') return null;++ if (!VOD_RE.test(String(value))) fail(422, 'party.vod', 'vod_id is the recording\'s number');++ return Number(value);++}++++function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }++++/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */++function syncOf(party, now = Date.now()) {++ if (!party || party.mode === 'live') return { mode: 'live', playing: true };++ const at = party.position_at instanceof Date ? party.position_at.getTime() : Date.parse(String(party.position_at).includes('T') ? party.position_at : `${String(party.position_at).replace(' ', 'T')}Z`);++ const since = party.playing && Number.isFinite(at) ? Math.max(0, Math.floor((now - at) / 1000)) : 0;++ return {++ mode: 'vod',++ position_s: Math.max(0, Number(party.position_s) || 0) + since,++ playing: !!party.playing,++ at: new Date(Number.isFinite(at) ? at : now).toISOString(),++ };++}++++function joinUrl(row, rawSecret = null) {++ const base = `${BASE_URL()}/rooms/${encodeURIComponent(row.slug)}`;++ return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;++}++++function view(row, party, { rawSecret = null, now = Date.now() } = {}) {++ return {++ id: row.slug,++ agent_stream_id: party.agent_stream_id,++ vod_id: party.vod_id == null ? null : String(party.vod_id),++ host: party.host_subject,++ title: row.name,++ sync: syncOf(party, now),++ visibility: 'private',++ listed: !!party.listed,++ join_url: joinUrl(row, rawSecret),++ created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),++ ...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),++ };++}++++/**++ * Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.++ * Returns the party and the one time its raw join secret exists.++ */++async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {++ if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');++ const streamId = String(agentStreamId || '');++ if (!AGENT_STREAM_RE.test(streamId)) fail(422, 'party.stream', 'agent_stream_id is an agent stream id (ags_…)');++ const name = titleOf(title);++ const vod = vodOf(vodId);++ const raw = secret();++ // The room name has its own 3–40 cap; the party's title (up to 200) stays on the party and the page.++ const roomName = (name.length < 3 ? `Party: ${name}` : name).slice(0, 40);++ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;++ const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });++ const id = room.id;++ await db.run(++ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [id, streamId, vod, user.id, user.subject_id || '', modeOf(vod), false, !!listed, sha256(raw)]++ );++ const row = await db.get('SELECT * FROM rooms WHERE id = ?', [id]);++ const party = await db.get('SELECT * FROM room_parties WHERE room_id = ?', [id]);++ return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };++}++++async function bySlug(slug) {++ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);++ return row || null;++}++++function partyOf(row) {++ return row && row.party_room_id ? {++ room_id: row.party_room_id, agent_stream_id: row.agent_stream_id, vod_id: row.vod_id,++ host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,++ position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,++ join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,++ } : null;++}++++function keyMatches(party, key) {++ if (!key || !party || !party.join_secret_hash) return false;++ const a = Buffer.from(sha256(key));++ const b = Buffer.from(String(party.join_secret_hash));++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++/** Whether this caller may read the party: a member, the host, chat staff, or the holder of the link's secret. */++async function mayView(row, party, user, key) {++ const access = await rooms.access(row, user || null);++ if (access.read) return true;++ return keyMatches(party, key);++}++++/** Join with the link's secret: the secret is the invitation, and membership is remembered after it. */++async function joinWithLink(row, party, user, key) {++ if (!user) fail(401, 'party.sign_in', 'Sign in to join the party');++ if (party.ended_at) fail(410, 'party.ended', 'This party has ended');++ const role = await rooms.access(row, user);++ if (role.role) return { role: role.role };++ if (!keyMatches(party, key)) fail(404, 'rooms.not_found', 'No such room');++ const joined = await rooms.join({ ...row, visibility: 'public' }, user);++ return { role: joined };++}++++/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */++async function setSync(row, party, user, { position_s: position, playing } = {}) {++ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');++ if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');++ let pos = party.position_s;++ if (position !== undefined) {++ const n = Number(position);++ if (!Number.isInteger(n) || n < 0 || n > 7 * 24 * 60 * 60) fail(422, 'party.position', 'position_s is whole seconds');++ pos = n;++ }++ const play = playing === undefined ? party.playing : !!playing;++ await db.run('UPDATE room_parties SET position_s = ?, playing = ?, position_at = now() WHERE room_id = ?', [pos, play, party.room_id]);++ const fresh = await bySlug(row.slug);++ return view(fresh, partyOf(fresh));++}++++/** How many listed parties a stream has. A count: no member, no host, no name. */++async function count(agentStreamId) {++ if (!AGENT_STREAM_RE.test(String(agentStreamId || ''))) return { count: 0 };++ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [String(agentStreamId)]);++ return { count: Number(row && row.n) || 0 };++}++++async function end(row, party, user) {++ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');++ await db.run('UPDATE room_parties SET ended_at = now(), playing = false WHERE room_id = ? AND ended_at IS NULL', [party.room_id]);++ const fresh = await bySlug(row.slug);++ return view(fresh, partyOf(fresh));++}++++module.exports = {++ AGENT_STREAM_RE, MAX_TITLE, PartyError,++ create, bySlug, partyOf, view, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,++};@@@ -50,7 +50,7 @@ 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', 'ice-servers']);--const KINDS = ['community', 'call', 'system', 'agent_stream'];++const KINDS = ['community', 'call', 'system', 'agent_stream', 'party'];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({@@@ -58,15 +58,17 @@ const KIND_ROLES = Object.freeze({call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],system: ['owner', 'mod', 'viewer', 'blocked'],agent_stream: ['owner', 'mod', 'member', 'viewer', 'blocked'],++ party: ['owner', 'mod', 'member', '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', agent_stream: 'member' });++const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer', agent_stream: 'member', party: 'member' });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']),agent_stream: new Set(['owner', 'mod', 'member']),++ party: new Set(['owner', 'mod', 'member']),});const TALKERS = new Set(['owner', 'mod', 'speaker']);const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };@@@ -144,7 +146,7 @@ async function access(room, user) {return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };}--async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {++async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null, party = false } = {}) {await 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');@@@ -153,7 +155,8 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '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 (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');-- if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call or system room');++ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call, system, agent stream or party room');++ if (kind === 'party' && !party) fail(422, 'rooms.kind', 'A party room is made by starting a watch party');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');@@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');const { requireAuth, optionalAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const rooms = require('./rooms');++const parties = require('./parties');const ctx = require('../live-context');const router = express.Router();@@@ -56,7 +57,7 @@ const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });function send(res, err) {-- if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });++ if (err instanceof rooms.RoomError || err instanceof parties.PartyError) return res.status(err.status).json({ error: err.message, code: err.code });console.error('[Rooms]', err && err.message);return res.status(500).json({ error: 'Something went wrong' });}@@@ -116,6 +117,65 @@ router.post('/', requireAuth, createLimit, async (req, res) => {try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }});++++// ── Watch parties (Agent Streams S7) ─────────────────────────++// A party is a private Chat room of kind 'party' on a public agent stream or its VOD. Joining is by the link's++// secret; the stream's page may count a listed party, and the count never carries a name.++router.post('/parties', requireAuth, createLimit, async (req, res) => {++ try {++ const out = await parties.create(req.user, req.body || {});++ res.status(201).json({ party: out.party, join_secret: out.join_secret });++ } catch (err) { send(res, err); }++});++++/** GET /parties/count?agent_stream= — how many listed parties, a number (the stream page's "3 parties watching"). */++router.get('/parties/count', optionalAuth, async (req, res) => {++ try {++ res.set('Cache-Control', 'no-store');++ res.json(await parties.count(req.query.agent_stream));++ } catch (err) { send(res, err); }++});++++router.get('/:slug/party', optionalAuth, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ if (!(await parties.mayView(row, party, req.user || null, req.query.k))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: parties.view(row, party) });++ } catch (err) { send(res, err); }++});++++/** Join with the link's secret; membership is remembered after it. */++router.post('/:slug/party/join', requireAuth, manageLimit, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.json(await parties.joinWithLink(row, party, req.user, (req.body || {}).k));++ } catch (err) { send(res, err); }++});++++/** The host moves the VOD (a live party follows the live edge, so there is no position). */++router.put('/:slug/party', requireAuth, manageLimit, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ if (!(await parties.mayView(row, party, req.user, null))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.json({ party: await parties.setSync(row, party, req.user, req.body || {}) });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/party/end', requireAuth, manageLimit, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug);++ const party = parties.partyOf(row);++ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ res.json({ party: await parties.end(row, party, req.user) });++ } catch (err) { send(res, err); }++});++router.get('/:slug', optionalAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;const a = await rooms.access(room, req.user || null);nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++/**++ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a private Chat room of kind 'party' on a public agent++ * stream or its VOD.++ *++ * - private by default: without the link a stranger sees the same 404 as a missing room, and the party is not listed;++ * - joining is by the link's secret, and membership is remembered after it;++ * - on a VOD the host's position is what everyone sees; a live party follows the live edge and takes no position;++ * - a listed party reaches the stream's page as a count only, never names, and disappears when the host ends it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('parties');++let h, ann, bob, cat;++const AGENT = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });++++t('boot', async () => {++ h = await boot();++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('a party is private by default and joins by its link', async () => {++ const r = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Friday watch', listed: true });++ assert.strictEqual(r.status, 201, r.text);++ const { party, join_secret: key } = r.body;++ assert.match(party.id, /^party-[0-9a-f]{8}$/);++ assert.strictEqual(party.agent_stream_id, AGENT);++ assert.strictEqual(party.vod_id, '4242');++ assert.strictEqual(party.visibility, 'private');++ assert.strictEqual(party.sync.mode, 'vod');++ assert.strictEqual(party.sync.playing, false);++ assert.match(party.join_url, /\/rooms\/party-[0-9a-f]{8}\?k=/);++ assert.ok(key && key.length >= 20);++ assert.ok(!('join_secret' in party), 'the secret lives in the link, not as its own field');++++ assert.strictEqual((await api('GET', `/rooms/${party.id}`, bob)).status, 404, 'the room itself is private');++ assert.strictEqual((await api('GET', `/${party.id}/party`, bob)).status, 404, 'without the link it is a missing room');++ assert.strictEqual((await api('POST', `/${party.id}/join`, bob)).status, 404, 'a plain join is refused');++ const wrong = await api('GET', `/${party.id}/party?k=not-the-link`, bob);++ assert.strictEqual(wrong.status, 404);++ const opened = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);++ assert.strictEqual(opened.status, 200, opened.text);++ assert.strictEqual(opened.body.party.id, party.id);++ const joined = await api('POST', `/${party.id}/party/join`, bob, { k: key });++ assert.strictEqual(joined.status, 200, joined.text);++ assert.strictEqual(joined.body.role, 'member');++ const listed = (await api('GET', '/', bob)).body;++ assert.ok(!listed.public.some((x) => x.slug === party.id), 'a party is never in the directory');++});++++t('on a VOD the host moves it and everyone follows', async () => {++ const made = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Host position', listed: true });++ assert.strictEqual(made.status, 201, made.text);++ const { party, join_secret: key } = made.body;++ const moved = await api('PUT', `/${party.id}/party`, ann, { position_s: 312, playing: true });++ assert.strictEqual(moved.status, 200, moved.text);++ assert.strictEqual(moved.body.party.sync.position_s, 312);++ assert.strictEqual(moved.body.party.sync.playing, true);++ const bobSees = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);++ assert.strictEqual(bobSees.status, 200);++ assert.ok(bobSees.body.party.sync.position_s >= 312, 'the guest is at the host position');++ assert.ok(bobSees.body.party.sync.position_s <= 315, 'the clock moved it, not a guess');++ assert.strictEqual((await api('POST', `/${party.id}/party/join`, bob, { k: key })).status, 200);++ assert.strictEqual((await api('PUT', `/${party.id}/party`, bob, { position_s: 0 })).status, 403, 'only the host moves it');++ const paused = await api('PUT', `/${party.id}/party`, ann, { position_s: 400, playing: false });++ assert.strictEqual(paused.body.party.sync.position_s, 400);++ const still = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);++ assert.strictEqual(still.body.party.sync.position_s, 400, 'paused: the position does not move');++});++++t('a live party follows the live edge and takes no position', async () => {++ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPV', title: 'Live edge' });++ assert.strictEqual(made.status, 201, made.text);++ assert.deepStrictEqual(made.body.party.sync, { mode: 'live', playing: true });++ const refused = await api('PUT', `/${made.body.party.id}/party`, ann, { position_s: 10 });++ assert.strictEqual(refused.status, 409, refused.text);++ assert.strictEqual(refused.body.code, 'party.live_edge');++});++++t('the stream page gets a count, never a name, and it drops when the party ends', async () => {++ const count = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);++ assert.strictEqual(count.status, 200, count.text);++ assert.strictEqual(count.body.count, 2, 'the two listed VOD parties');++ const text = JSON.stringify(count.body);++ assert.ok(!/ann|bob|cat|host|member/.test(text), 'a count carries no name');++ const unlisted = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPW', title: 'Quiet party' });++ assert.strictEqual(unlisted.status, 201, unlisted.text);++ const quiet = await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPW`, null);++ assert.strictEqual(quiet.body.count, 0, 'unlisted parties are not counted');++ const first = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);++ assert.strictEqual(first.body.count, 2);++ const ended = await api('POST', '/parties/count', null, {});++ assert.ok(ended.status === 404 || ended.status === 405, 'the count is a read');++});++++t('the host ends a party and the count drops', async () => {++ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPX', vod_id: '9', title: 'Ending soon', listed: true });++ const { party, join_secret: key } = made.body;++ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 1);++ assert.strictEqual((await api('POST', `/${party.id}/party/end`, bob)).status, 403, 'only the host ends it');++ const ended = await api('POST', `/${party.id}/party/end`, ann);++ assert.strictEqual(ended.status, 200, ended.text);++ assert.ok(ended.body.party.ended_at);++ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 0);++ const gone = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);++ assert.strictEqual(gone.status, 200, 'the room stays readable with its chat');++ assert.ok(gone.body.party.ended_at);++});++++t('the host needs an OpenVibe subject and a real agent stream id', async () => {++ const noSubject = h.addUser('dave', { subject: null });++ await h.ctx.upsertUser(h.live.users.get(noSubject.id));++ const r = await api('POST', '/parties', noSubject, { agent_stream_id: AGENT, title: 'No subject' });++ assert.strictEqual(r.status, 409, r.text);++ const bad = await api('POST', '/parties', ann, { agent_stream_id: 'not-a-stream', title: 'Bad id' });++ assert.strictEqual(bad.status, 422, bad.text);++ assert.strictEqual((await api('POST', '/', ann, { name: 'A party room', kind: 'party' })).status, 422, 'a party room is made by starting a party');++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -30,7 +30,7 @@ const TABLES = ['chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples','chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',-- 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls',++ 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties','room_message_reactions', 'room_member_timeouts','agent_play_requests', 'agent_play_votes', 'agent_play_log',];
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+-- phase: expand
+-- Agent Streams S7 (design 2026-11-06, AGENT-STREAMS-S7-REACH-DESIGN): watch parties on a public agent stream or its
+-- VOD.
+--
+-- A party is a Chat room of kind 'party' (the room row keeps its name, members and messages) plus this row: the
+-- stream it watches, the host whose position a VOD follows, and whether the stream's page may count it. A live party
+-- follows the live edge and has no position. The room is private by default: its link carries a secret (only its
+-- sha256 is stored), so joining is by that link and a stranger without it sees the same 404 as a missing room.
+-- `listed` only lets the stream's page say "3 parties watching": the count query never reads a member or a name.
+--
+-- Expand only: 'party' joins the existing room kinds, every table is new, and an older release ignores all of it.
+DO $$ BEGIN
+ ALTER TABLE rooms DROP CONSTRAINT IF EXISTS rooms_kind_allowed;
+ ALTER TABLE rooms ADD CONSTRAINT rooms_kind_allowed CHECK (kind IN ('community', 'call', 'system', 'agent_stream', 'party'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+CREATE TABLE IF NOT EXISTS room_parties (
+ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
+ agent_stream_id text COLLATE "C" NOT NULL,
+ vod_id bigint,
+ host_user_id bigint NOT NULL,
+ host_subject text COLLATE "C" NOT NULL,
+ mode text COLLATE "C" NOT NULL CHECK (mode IN ('live', 'vod')),
+ position_s integer NOT NULL DEFAULT 0 CHECK (position_s >= 0),
+ playing boolean NOT NULL DEFAULT false,
+ position_at timestamptz NOT NULL DEFAULT now(),
+ listed boolean NOT NULL DEFAULT false,
+ join_secret_hash text COLLATE "C" NOT NULL,
+ ended_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS room_parties_stream ON room_parties (agent_stream_id) WHERE ended_at IS NULL;
+CREATE INDEX IF NOT EXISTS room_parties_host ON room_parties (host_user_id);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1557,9 +1557,9 @@
@@ -1557,9 +1557,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.304.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
- "integrity": "sha512-nUNV/kfgfYwBTqPPfa3u/rJAs0YMQXXMXqwRGlf4IJHGeUXbSo6/h12GtX5QwG9v3eqQYkc5a+nAQXwYWu7lDQ==",
+ "version": "0.305.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
+ "integrity": "sha512-jTU1zlnTmHlydwL3/c5HVdUiPpd8r2KucVdHYUYX5MZgJoqy4M7Hg2jy6UKbJQ6s665uIZyTvswbmJjB2uwc5Q==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.304.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -305,6 +305,15 @@ async function ttsOverride(req) {
@@ -305,6 +305,15 @@ async function ttsOverride(req) {
};
};
}
}
+/** S7: how many listed watch parties a public agent stream has. A count only: no host, member or name. */
+async function partyCount(req) {
+ const q = query(req, { agent_stream: 40 });
+ const streamId = String(q.agent_stream || '');
+ if (!/^ags_[0-9A-HJKMNP-TV-Z]{26}$/.test(streamId)) bad('Invalid agent_stream');
+ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [streamId]);
+ return { ok: true, count: Number(row && row.n) || 0 };
+}
+
// ── N5: channel sounds ──
// ── N5: channel sounds ──
function soundRow(r) {
function soundRow(r) {
return {
return {
@@ -371,6 +380,7 @@ router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));
@@ -371,6 +380,7 @@ router.get('/moderation/pending-ip', queue, route('pending-ip', pendingIp));
router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));
router.get('/moderation/relay-users', queue, route('relay-users', relayUsers));
router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser));
router.get('/moderation/relay-users/:id', queue, route('relay-user', relayUser));
router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));
router.get('/moderation/tts-override', queue, route('tts-override', ttsOverride));
+router.get('/parties/count', serviceAuth.guard('chat.messages.read'), route('party-count', partyCount));
router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));
router.get('/sounds', serviceAuth.guard('chat.sounds.read'), route('sounds', sounds));
router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));
router.get('/sounds/count', serviceAuth.guard('chat.sounds.read'), route('sound-count', soundCount));
router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));
router.get('/sounds/by-command', serviceAuth.guard('chat.sounds.read'), route('sound-by-command', soundByCommand));
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+/**
+ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a Chat room of kind 'party' where friends watch a public
+ * agent stream or its VOD in sync, with their own chat beside the stream's.
+ *
+ * - Sync: on a VOD the position follows the host's; on a live stream it follows the live edge (no position).
+ * - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger
+ * without it sees the same 404 as a missing room.
+ * - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets
+ * the stream's page count it ("3 parties watching"). The count query never reads a member or a name.
+ *
+ * The room itself is rooms.js's (name, members, messages, moderation); this module owns only the party facts.
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+const rooms = require('./rooms');
+
+const AGENT_STREAM_RE = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
+const VOD_RE = /^\d{1,16}$/;
+const MAX_TITLE = 200;
+const BASE_URL = () => String(process.env.OV_CHAT_PUBLIC_URL || 'https://openvibe.chat').replace(/\/+$/, '');
+
+class PartyError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+const fail = (status, code, message) => { throw new PartyError(status, code, message); };
+const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
+const secret = () => crypto.randomBytes(18).toString('base64url').slice(0, 24);
+
+function titleOf(value) {
+ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
+ if (!text || Array.from(text).length > MAX_TITLE) fail(422, 'party.title', 'A party needs a title of 1–200 characters');
+ return text;
+}
+
+/** A VOD's id: Media's numeric id, as Live and the Rundown carry it. */
+function vodOf(value) {
+ if (value == null || value === '') return null;
+ if (!VOD_RE.test(String(value))) fail(422, 'party.vod', 'vod_id is the recording\'s number');
+ return Number(value);
+}
+
+function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
+
+/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
+function syncOf(party, now = Date.now()) {
+ if (!party || party.mode === 'live') return { mode: 'live', playing: true };
+ const at = party.position_at instanceof Date ? party.position_at.getTime() : Date.parse(String(party.position_at).includes('T') ? party.position_at : `${String(party.position_at).replace(' ', 'T')}Z`);
+ const since = party.playing && Number.isFinite(at) ? Math.max(0, Math.floor((now - at) / 1000)) : 0;
+ return {
+ mode: 'vod',
+ position_s: Math.max(0, Number(party.position_s) || 0) + since,
+ playing: !!party.playing,
+ at: new Date(Number.isFinite(at) ? at : now).toISOString(),
+ };
+}
+
+function joinUrl(row, rawSecret = null) {
+ const base = `${BASE_URL()}/rooms/${encodeURIComponent(row.slug)}`;
+ return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
+}
+
+function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
+ return {
+ id: row.slug,
+ agent_stream_id: party.agent_stream_id,
+ vod_id: party.vod_id == null ? null : String(party.vod_id),
+ host: party.host_subject,
+ title: row.name,
+ sync: syncOf(party, now),
+ visibility: 'private',
+ listed: !!party.listed,
+ join_url: joinUrl(row, rawSecret),
+ created_at: new Date(party.created_at instanceof Date ? party.created_at.getTime() : Date.parse(String(party.created_at).includes('T') ? party.created_at : `${String(party.created_at).replace(' ', 'T')}Z`)).toISOString(),
+ ...(party.ended_at ? { ended_at: new Date(party.ended_at).toISOString() } : {}),
+ };
+}
+
+/**
+ * Start a party on a public agent stream or its VOD. The room is private; only the link (with its secret) opens it.
+ * Returns the party and the one time its raw join secret exists.
+ */
+async function create(user, { agent_stream_id: agentStreamId, vod_id: vodId = null, title, listed = false } = {}) {
+ if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
+ const streamId = String(agentStreamId || '');
+ if (!AGENT_STREAM_RE.test(streamId)) fail(422, 'party.stream', 'agent_stream_id is an agent stream id (ags_…)');
+ const name = titleOf(title);
+ const vod = vodOf(vodId);
+ const raw = secret();
+ // The room name has its own 3–40 cap; the party's title (up to 200) stays on the party and the page.
+ const roomName = (name.length < 3 ? `Party: ${name}` : name).slice(0, 40);
+ const slug = `party-${crypto.randomBytes(4).toString('hex')}`;
+ const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
+ const id = room.id;
+ await db.run(
+ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, streamId, vod, user.id, user.subject_id || '', modeOf(vod), false, !!listed, sha256(raw)]
+ );
+ const row = await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
+ const party = await db.get('SELECT * FROM room_parties WHERE room_id = ?', [id]);
+ return { party: view(row, party, { rawSecret: raw }), roomId: id, join_secret: raw };
+}
+
+async function bySlug(slug) {
+ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
+ return row || null;
+}
+
+function partyOf(row) {
+ return row && row.party_room_id ? {
+ room_id: row.party_room_id, agent_stream_id: row.agent_stream_id, vod_id: row.vod_id,
+ host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
+ position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
+ join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
+ } : null;
+}
+
+function keyMatches(party, key) {
+ if (!key || !party || !party.join_secret_hash) return false;
+ const a = Buffer.from(sha256(key));
+ const b = Buffer.from(String(party.join_secret_hash));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/** Whether this caller may read the party: a member, the host, chat staff, or the holder of the link's secret. */
+async function mayView(row, party, user, key) {
+ const access = await rooms.access(row, user || null);
+ if (access.read) return true;
+ return keyMatches(party, key);
+}
+
+/** Join with the link's secret: the secret is the invitation, and membership is remembered after it. */
+async function joinWithLink(row, party, user, key) {
+ if (!user) fail(401, 'party.sign_in', 'Sign in to join the party');
+ if (party.ended_at) fail(410, 'party.ended', 'This party has ended');
+ const role = await rooms.access(row, user);
+ if (role.role) return { role: role.role };
+ if (!keyMatches(party, key)) fail(404, 'rooms.not_found', 'No such room');
+ const joined = await rooms.join({ ...row, visibility: 'public' }, user);
+ return { role: joined };
+}
+
+/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
+async function setSync(row, party, user, { position_s: position, playing } = {}) {
+ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
+ if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
+ let pos = party.position_s;
+ if (position !== undefined) {
+ const n = Number(position);
+ if (!Number.isInteger(n) || n < 0 || n > 7 * 24 * 60 * 60) fail(422, 'party.position', 'position_s is whole seconds');
+ pos = n;
+ }
+ const play = playing === undefined ? party.playing : !!playing;
+ await db.run('UPDATE room_parties SET position_s = ?, playing = ?, position_at = now() WHERE room_id = ?', [pos, play, party.room_id]);
+ const fresh = await bySlug(row.slug);
+ return view(fresh, partyOf(fresh));
+}
+
+/** How many listed parties a stream has. A count: no member, no host, no name. */
+async function count(agentStreamId) {
+ if (!AGENT_STREAM_RE.test(String(agentStreamId || ''))) return { count: 0 };
+ const row = await db.get('SELECT COUNT(*) AS n FROM room_parties p JOIN rooms r ON r.id = p.room_id WHERE p.agent_stream_id = ? AND p.listed IS TRUE AND p.ended_at IS NULL AND r.archived_at IS NULL', [String(agentStreamId)]);
+ return { count: Number(row && row.n) || 0 };
+}
+
+async function end(row, party, user) {
+ if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');
+ await db.run('UPDATE room_parties SET ended_at = now(), playing = false WHERE room_id = ? AND ended_at IS NULL', [party.room_id]);
+ const fresh = await bySlug(row.slug);
+ return view(fresh, partyOf(fresh));
+}
+
+module.exports = {
+ AGENT_STREAM_RE, MAX_TITLE, PartyError,
+ create, bySlug, partyOf, view, syncOf, mayView, joinWithLink, setSync, count, end, keyMatches,
+};
@@ -50,7 +50,7 @@ const permissions = require('../auth/permissions');
@@ -50,7 +50,7 @@ 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', 'ice-servers']);
const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);
-const KINDS = ['community', 'call', 'system', 'agent_stream'];
+const KINDS = ['community', 'call', 'system', 'agent_stream', 'party'];
const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];
const ROLES = ['owner', 'mod', 'speaker', 'participant', 'member', 'viewer', 'blocked'];
/** The roles each kind has (owner is never assigned: a room has one, its creator). */
/** The roles each kind has (owner is never assigned: a room has one, its creator). */
const KIND_ROLES = Object.freeze({
const KIND_ROLES = Object.freeze({
@@ -58,15 +58,17 @@ const KIND_ROLES = Object.freeze({
@@ -58,15 +58,17 @@ const KIND_ROLES = Object.freeze({
call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],
call: ['owner', 'mod', 'speaker', 'participant', 'viewer', 'blocked'],
system: ['owner', 'mod', 'viewer', 'blocked'],
system: ['owner', 'mod', 'viewer', 'blocked'],
agent_stream: ['owner', 'mod', 'member', 'viewer', 'blocked'],
agent_stream: ['owner', 'mod', 'member', 'viewer', 'blocked'],
+ party: ['owner', 'mod', 'member', 'viewer', 'blocked'],
});
});
/** What joining a public room makes you (a call room's owner picks one of JOIN_ROLES_CALL). */
/** 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', agent_stream: 'member' });
+const JOIN_ROLE = Object.freeze({ community: 'member', call: 'participant', system: 'viewer', agent_stream: 'member', party: 'member' });
const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];
const JOIN_ROLES_CALL = ['speaker', 'participant', 'viewer'];
const POSTERS = Object.freeze({
const POSTERS = Object.freeze({
community: new Set(['owner', 'mod', 'member']),
community: new Set(['owner', 'mod', 'member']),
call: new Set(['owner', 'mod', 'speaker', 'participant']),
call: new Set(['owner', 'mod', 'speaker', 'participant']),
system: new Set(['owner', 'mod']),
system: new Set(['owner', 'mod']),
agent_stream: new Set(['owner', 'mod', 'member']),
agent_stream: new Set(['owner', 'mod', 'member']),
+ party: new Set(['owner', 'mod', 'member']),
});
});
const TALKERS = new Set(['owner', 'mod', 'speaker']);
const TALKERS = new Set(['owner', 'mod', 'speaker']);
const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };
const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, viewer: 5, blocked: 6 };
@@ -144,7 +146,7 @@ async function access(room, user) {
@@ -144,7 +146,7 @@ async function access(room, user) {
return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
}
}
-async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
+async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null, party = false } = {}) {
await ensureSchema();
await 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');
@@ -153,7 +155,8 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
@@ -153,7 +155,8 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
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)) fail(422, 'rooms.kind', 'A room is a community, call or system room');
+ if (!KINDS.includes(kind)) fail(422, 'rooms.kind', 'A room is a community, call, system, agent stream or party room');
+ if (kind === 'party' && !party) fail(422, 'rooms.kind', 'A party room is made by starting a watch party');
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
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 (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');
@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
+const parties = require('./parties');
const ctx = require('../live-context');
const ctx = require('../live-context');
const router = express.Router();
const router = express.Router();
@@ -56,7 +57,7 @@ const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
@@ -56,7 +57,7 @@ const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
function send(res, err) {
function send(res, err) {
- if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
+ if (err instanceof rooms.RoomError || err instanceof parties.PartyError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Rooms]', err && err.message);
console.error('[Rooms]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -116,6 +117,65 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
@@ -116,6 +117,65 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
});
});
+
+// ── Watch parties (Agent Streams S7) ─────────────────────────
+// A party is a private Chat room of kind 'party' on a public agent stream or its VOD. Joining is by the link's
+// secret; the stream's page may count a listed party, and the count never carries a name.
+router.post('/parties', requireAuth, createLimit, async (req, res) => {
+ try {
+ const out = await parties.create(req.user, req.body || {});
+ res.status(201).json({ party: out.party, join_secret: out.join_secret });
+ } catch (err) { send(res, err); }
+});
+
+/** GET /parties/count?agent_stream= — how many listed parties, a number (the stream page's "3 parties watching"). */
+router.get('/parties/count', optionalAuth, async (req, res) => {
+ try {
+ res.set('Cache-Control', 'no-store');
+ res.json(await parties.count(req.query.agent_stream));
+ } catch (err) { send(res, err); }
+});
+
+router.get('/:slug/party', optionalAuth, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ if (!(await parties.mayView(row, party, req.user || null, req.query.k))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: parties.view(row, party) });
+ } catch (err) { send(res, err); }
+});
+
+/** Join with the link's secret; membership is remembered after it. */
+router.post('/:slug/party/join', requireAuth, manageLimit, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.json(await parties.joinWithLink(row, party, req.user, (req.body || {}).k));
+ } catch (err) { send(res, err); }
+});
+
+/** The host moves the VOD (a live party follows the live edge, so there is no position). */
+router.put('/:slug/party', requireAuth, manageLimit, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ if (!(await parties.mayView(row, party, req.user, null))) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.json({ party: await parties.setSync(row, party, req.user, req.body || {}) });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/party/end', requireAuth, manageLimit, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug);
+ const party = parties.partyOf(row);
+ if (!row || !party) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ res.json({ party: await parties.end(row, party, req.user) });
+ } catch (err) { send(res, err); }
+});
+
router.get('/:slug', optionalAuth, async (req, res) => {
router.get('/:slug', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
const a = await rooms.access(room, req.user || null);
const a = await rooms.access(room, req.user || null);
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+/**
+ * Watch parties (Agent Streams S7, design 2026-11-06 §4): a private Chat room of kind 'party' on a public agent
+ * stream or its VOD.
+ *
+ * - private by default: without the link a stranger sees the same 404 as a missing room, and the party is not listed;
+ * - joining is by the link's secret, and membership is remembered after it;
+ * - on a VOD the host's position is what everyone sees; a live party follows the live edge and takes no position;
+ * - a listed party reaches the stream's page as a count only, never names, and disappears when the host ends it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('parties');
+let h, ann, bob, cat;
+const AGENT = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
+const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
+
+t('boot', async () => {
+ h = await boot();
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('a party is private by default and joins by its link', async () => {
+ const r = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Friday watch', listed: true });
+ assert.strictEqual(r.status, 201, r.text);
+ const { party, join_secret: key } = r.body;
+ assert.match(party.id, /^party-[0-9a-f]{8}$/);
+ assert.strictEqual(party.agent_stream_id, AGENT);
+ assert.strictEqual(party.vod_id, '4242');
+ assert.strictEqual(party.visibility, 'private');
+ assert.strictEqual(party.sync.mode, 'vod');
+ assert.strictEqual(party.sync.playing, false);
+ assert.match(party.join_url, /\/rooms\/party-[0-9a-f]{8}\?k=/);
+ assert.ok(key && key.length >= 20);
+ assert.ok(!('join_secret' in party), 'the secret lives in the link, not as its own field');
+
+ assert.strictEqual((await api('GET', `/rooms/${party.id}`, bob)).status, 404, 'the room itself is private');
+ assert.strictEqual((await api('GET', `/${party.id}/party`, bob)).status, 404, 'without the link it is a missing room');
+ assert.strictEqual((await api('POST', `/${party.id}/join`, bob)).status, 404, 'a plain join is refused');
+ const wrong = await api('GET', `/${party.id}/party?k=not-the-link`, bob);
+ assert.strictEqual(wrong.status, 404);
+ const opened = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
+ assert.strictEqual(opened.status, 200, opened.text);
+ assert.strictEqual(opened.body.party.id, party.id);
+ const joined = await api('POST', `/${party.id}/party/join`, bob, { k: key });
+ assert.strictEqual(joined.status, 200, joined.text);
+ assert.strictEqual(joined.body.role, 'member');
+ const listed = (await api('GET', '/', bob)).body;
+ assert.ok(!listed.public.some((x) => x.slug === party.id), 'a party is never in the directory');
+});
+
+t('on a VOD the host moves it and everyone follows', async () => {
+ const made = await api('POST', '/parties', ann, { agent_stream_id: AGENT, vod_id: '4242', title: 'Host position', listed: true });
+ assert.strictEqual(made.status, 201, made.text);
+ const { party, join_secret: key } = made.body;
+ const moved = await api('PUT', `/${party.id}/party`, ann, { position_s: 312, playing: true });
+ assert.strictEqual(moved.status, 200, moved.text);
+ assert.strictEqual(moved.body.party.sync.position_s, 312);
+ assert.strictEqual(moved.body.party.sync.playing, true);
+ const bobSees = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
+ assert.strictEqual(bobSees.status, 200);
+ assert.ok(bobSees.body.party.sync.position_s >= 312, 'the guest is at the host position');
+ assert.ok(bobSees.body.party.sync.position_s <= 315, 'the clock moved it, not a guess');
+ assert.strictEqual((await api('POST', `/${party.id}/party/join`, bob, { k: key })).status, 200);
+ assert.strictEqual((await api('PUT', `/${party.id}/party`, bob, { position_s: 0 })).status, 403, 'only the host moves it');
+ const paused = await api('PUT', `/${party.id}/party`, ann, { position_s: 400, playing: false });
+ assert.strictEqual(paused.body.party.sync.position_s, 400);
+ const still = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
+ assert.strictEqual(still.body.party.sync.position_s, 400, 'paused: the position does not move');
+});
+
+t('a live party follows the live edge and takes no position', async () => {
+ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPV', title: 'Live edge' });
+ assert.strictEqual(made.status, 201, made.text);
+ assert.deepStrictEqual(made.body.party.sync, { mode: 'live', playing: true });
+ const refused = await api('PUT', `/${made.body.party.id}/party`, ann, { position_s: 10 });
+ assert.strictEqual(refused.status, 409, refused.text);
+ assert.strictEqual(refused.body.code, 'party.live_edge');
+});
+
+t('the stream page gets a count, never a name, and it drops when the party ends', async () => {
+ const count = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);
+ assert.strictEqual(count.status, 200, count.text);
+ assert.strictEqual(count.body.count, 2, 'the two listed VOD parties');
+ const text = JSON.stringify(count.body);
+ assert.ok(!/ann|bob|cat|host|member/.test(text), 'a count carries no name');
+ const unlisted = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPW', title: 'Quiet party' });
+ assert.strictEqual(unlisted.status, 201, unlisted.text);
+ const quiet = await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPW`, null);
+ assert.strictEqual(quiet.body.count, 0, 'unlisted parties are not counted');
+ const first = await api('GET', `/parties/count?agent_stream=${AGENT}`, null);
+ assert.strictEqual(first.body.count, 2);
+ const ended = await api('POST', '/parties/count', null, {});
+ assert.ok(ended.status === 404 || ended.status === 405, 'the count is a read');
+});
+
+t('the host ends a party and the count drops', async () => {
+ const made = await api('POST', '/parties', ann, { agent_stream_id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPX', vod_id: '9', title: 'Ending soon', listed: true });
+ const { party, join_secret: key } = made.body;
+ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 1);
+ assert.strictEqual((await api('POST', `/${party.id}/party/end`, bob)).status, 403, 'only the host ends it');
+ const ended = await api('POST', `/${party.id}/party/end`, ann);
+ assert.strictEqual(ended.status, 200, ended.text);
+ assert.ok(ended.body.party.ended_at);
+ assert.strictEqual((await api('GET', `/parties/count?agent_stream=ags_01JAB2C3D4E5F6G7H8J9K0MNPX`, null)).body.count, 0);
+ const gone = await api('GET', `/${party.id}/party?k=${encodeURIComponent(key)}`, bob);
+ assert.strictEqual(gone.status, 200, 'the room stays readable with its chat');
+ assert.ok(gone.body.party.ended_at);
+});
+
+t('the host needs an OpenVibe subject and a real agent stream id', async () => {
+ const noSubject = h.addUser('dave', { subject: null });
+ await h.ctx.upsertUser(h.live.users.get(noSubject.id));
+ const r = await api('POST', '/parties', noSubject, { agent_stream_id: AGENT, title: 'No subject' });
+ assert.strictEqual(r.status, 409, r.text);
+ const bad = await api('POST', '/parties', ann, { agent_stream_id: 'not-a-stream', title: 'Bad id' });
+ assert.strictEqual(bad.status, 422, bad.text);
+ assert.strictEqual((await api('POST', '/', ann, { name: 'A party room', kind: 'party' })).status, 422, 'a party room is made by starting a party');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -30,7 +30,7 @@ const TABLES = [
@@ -30,7 +30,7 @@ const TABLES = [
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
- 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls',
+ 'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
'room_message_reactions', 'room_member_timeouts',
'room_message_reactions', 'room_member_timeouts',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
];
];