openvibers/openvibe.live Commits
CALLS_AUTHORITY flag (WS-I task 1): with CALLS_AUTHORITY=chat the go-live, stream-end, WHIP teardown and admin force-end hooks create and remove stream voice channels in OpenVibe.Chat (POST/DELETE /internal/calls/stream-channel with the chat bridge's service token, retried on 5xx, superseded retries dropped) instead of Live's call server, and Live pushes Chat's call-user notification (notify/call-invite); the default live changes nothing and Live's /ws/call and voice-channel routes stay for one release
Changes
@@@ -158,6 +158,12 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.#CHAT_AUTHORITY=chatOV_CHAT_INTERNAL_URL=http://127.0.0.1:4400++# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;++# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end++# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat++# bridge's service token) instead of Live's call server. Set it together with the nginx include that++# sends /ws/call and the call routes to Chat (OpenVibe.Chat docs/calls-cutover.md). Unset = Live.++#CALLS_AUTHORITY=chat# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,@@@ -88,7 +88,11 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-meshWebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existingmembers answer; the server pushes the channel list to every chat socket (`voice-channels` message);-- TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`).++ TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). OpenVibe.Chat++ runs the same protocol and routes (its `server/calls/`); `CALLS_AUTHORITY=chat`++ (`server/streaming/calls-authority.js`) plus an nginx include moves calls there, and then go-live,++ stream end, WHIP teardown and the admin force-end create/remove stream voice channels in Chat++ (OpenVibe.Chat `docs/calls-cutover.md`).- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eagerJavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.@@@ -344,7 +344,7 @@ router.delete('/streams/:id', (req, res) => {try { require('../integrations/ai-chatbot-service').stopForStream(stream.id); } catch { /* non-critical */ }try { require('../streaming/broadcast-server').endStream(stream.id); } catch { /* no room */ }try { require('../streaming/webrtc-sfu').closeRoom(`stream-${stream.id}`); } catch { /* no room */ }-- try { require('../streaming/call-server').removeStreamChannel(stream.id); } catch { /* no channel */ }++ try { require('../streaming/calls-authority').removeStreamChannel(stream.id); } catch { /* no channel */ }// Accountability: force-ending someone's live stream is a moderation action// and MUST be logged (previously it left no trace at all).@@@ -24,7 +24,7 @@* /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,-- * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync++ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync* mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy** Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat@@@ -553,6 +553,36 @@ effectsRouter.post('/notify/dm', (req, res) => {res.json({ ok: true });});++// A call-user ring from OpenVibe.Chat's call server (CALLS_AUTHORITY=chat): the cross-site++// notification Live's POST /api/streams/voice-channels/call-user pushed itself.++effectsRouter.post('/notify/call-invite', (req, res) => {++ const caller = db.getUserById(int(req.body?.caller_id));++ const targetId = int(req.body?.target_id);++ const channelId = String(req.body?.channel_id || '').slice(0, 100);++ const channelName = String(req.body?.channel_name || 'Voice Channel').slice(0, 100);++ if (!caller || !targetId || !db.getUserById(targetId) || !channelId) return fail(res, 400, 'caller_id, target_id and channel_id required');++ try {++ const { pushNotification, actorInfo } = require('../utils/notify');++ const callerName = caller.display_name || caller.username || 'Someone';++ pushNotification({++ user_id: targetId,++ type: 'VC_CALL_INVITE',++ title: `${callerName} is calling you`,++ message: `Join voice channel: ${channelName}`,++ url: `${config.baseUrl}/?vcInvite=${encodeURIComponent(channelId)}`,++ rich_content: {++ context: {++ channel_id: channelId,++ channel_name: channelName,++ caller_username: caller.username,++ },++ },++ ...actorInfo(caller, callerName),++ });++ } catch { /* non-critical */ }++ res.json({ ok: true });++});++effectsRouter.post('/notify/dm-read', (req, res) => {try {const { markNotificationsRead } = require('../utils/notify');@@@ -1184,6 +1184,9 @@ async function start() {// 4d. Initialize group call signaling servercallServer.init(server);++ if (require('./streaming/calls-authority').isChat()) {++ console.log('[Calls] CALLS_AUTHORITY=chat — stream voice channels are created and removed in OpenVibe.Chat; Live\'s /ws/call stays up, unreached once nginx routes it to Chat');++ }for (const stream of db.getLiveStreams()) {robotStreamerService.startForStream(stream).catch((err) => {nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * Who runs voice/video calls (roadmap WS-I task 1). CALLS_AUTHORITY is read here and nowhere else.++ *++ * live (default, anything but "chat"): Live's own call server (./call-server.js) serves /ws/call and++ * the voice-channel routes, and the stream lifecycle creates and removes stream voice channels in++ * it — exactly as before.++ * chat: OpenVibe.Chat owns calls (nginx sends /ws/call, /api/streams/voice-channels… and++ * /api/streams/:id/call there; Chat runs with CHAT_CALLS=1 — OpenVibe.Chat docs/calls-cutover.md).++ * Go-live with a call mode, stream end, the WHIP teardown and the admin force-end ask Chat instead++ * of the local call server:++ * POST /internal/calls/stream-channel { stream_id, mode, user_id }++ * DELETE /internal/calls/stream-channel/:streamId++ * with Live's service token for audience openvibe.chat — the same principal and grant++ * (chat.live_bridge.write) Live's chat bridge uses (../chat/chat-remote.js). Fire-and-forget for the++ * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the++ * same stream superseded it (a stream that ended while its go-live was being retried is not++ * given a channel afterwards); a 4xx is logged and dropped. Live's /ws/call and voice-channel++ * routes stay mounted for one release, unreached once nginx routes those paths to Chat.++ */++const { CHAT_URL } = require('../chat/chat-authority');++++const AUDIENCE = 'openvibe.chat';++const RETRY_MS = [1000, 5000, 15000];++const TIMEOUT_MS = 5000;++++function authority() {++ return String(process.env.CALLS_AUTHORITY || '').trim().toLowerCase() === 'chat' ? 'chat' : 'live';++}++function isChat() { return authority() === 'chat'; }++++const local = () => require('./call-server');++++async function send(method, path, body, retried = false) {++ const principal = require('../net/network-principal');++ const res = await fetch(`${CHAT_URL}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await principal.serviceHeaders(AUDIENCE)) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return send(method, path, body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error(`Chat ${res.status}${data && data.error ? `: ${data.error}` : ''}`);++ err.status = res.status;++ throw err;++ }++ return data;++}++++const latest = new Map(); // streamId → seq of the newest call for it++let seq = 0;++const stats = { sent: 0, retried: 0, superseded: 0, failed: 0, lastError: null };++++/** Send one hook to Chat, retrying as above. Resolves (never rejects) with Chat's answer or null. */++function deliver(streamId, method, path, body) {++ const mine = ++seq;++ latest.set(streamId, mine);++ const attempt = async (n) => {++ if (latest.get(streamId) !== mine) { stats.superseded++; return null; }++ try {++ const out = await send(method, path, body);++ stats.sent++;++ if (latest.get(streamId) === mine) latest.delete(streamId);++ return out;++ } catch (err) {++ stats.lastError = err.message;++ const retry = !(err.status >= 400 && err.status < 500) && n < RETRY_MS.length;++ if (!retry) {++ stats.failed++;++ console.warn(`[Calls] Chat refused ${method} ${path}: ${err.message}`);++ if (latest.get(streamId) === mine) latest.delete(streamId);++ return null;++ }++ stats.retried++;++ console.warn(`[Calls] ${method} ${path} failed (${err.message}); retrying in ${RETRY_MS[n] / 1000}s`);++ await new Promise((r) => { const t = setTimeout(r, RETRY_MS[n]); if (t.unref) t.unref(); });++ return attempt(n + 1);++ }++ };++ return attempt(0);++}++++/** Go-live with a call mode, or the mode changed: the stream's voice channel. */++function createStreamChannel(streamId, mode, userId) {++ if (!isChat()) return local().createStreamChannel(streamId, mode, userId);++ return deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });++}++++/** The stream ended (or was force-ended): its call ends and its voice channel goes. */++function removeStreamChannel(streamId) {++ if (!isChat()) return local().removeStreamChannel(streamId);++ return deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);++}++++module.exports = { authority, isChat, createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };@@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');const robotStreamerService = require('../integrations/robotstreamer-service');const chatRelayService = require('../integrations/chat-relay-service');const chatServer = require('../chat/chat-server');++// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.++const callsAuthority = require('./calls-authority');const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');const multer = require('multer');const path = require('path');@@@ -2044,7 +2046,7 @@ router.post('/', requireAuth, (req, res) => {// Set call mode if provided — create a stream voice channelif (callMode) {db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [callMode, streamId]);-- callServer.createStreamChannel(streamId, callMode, req.user.id);++ callsAuthority.createStreamChannel(streamId, callMode, req.user.id);}let endpoint = {};@@@ -2179,7 +2181,7 @@ router.delete('/:id', requireAuth, (req, res) => {}// End any active group call / remove stream voice channel-- callServer.removeStreamChannel(stream.id);++ callsAuthority.removeStreamChannel(stream.id);robotStreamerService.stopForStream(stream.id);chatRelayService.stopForStream(stream.id);@@@ -203,7 +203,7 @@ function endActiveWhipStream(streamId, reason = 'whip_cleanup') {try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch (err) { /* ignore */ }try { require('./broadcast-server').endStream(streamId); } catch (err) { /* ignore */ }try { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }-- try { require('./call-server').removeStreamChannel(streamId); } catch (err) { /* ignore */ }++ try { require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }}// ── SDP Parsing Utilities ────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++++// Who runs calls (WS-I task 1, server/streaming/calls-authority.js). CALLS_AUTHORITY unset (or++// anything but "chat"): stream voice channels are made and removed by Live's own call server and++// nothing is sent anywhere — as before. "chat": the same hooks become POST/DELETE++// /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for audience++// openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later++// call for the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end++// hooks all go through it. Against a stub Network + Chat.++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-calls-authority-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.OV_OAUTH_CLIENT_ID = 'live';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++delete process.env.CALLS_AUTHORITY;++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++const seen = []; // requests to Chat's /internal/calls++const tokenAudiences = [];++let answer = () => [200, { ok: true }];++const stub = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') {++ const aud = new URLSearchParams(raw).get('audience');++ tokenAudiences.push(aud);++ return res.end(JSON.stringify({ access_token: `svc-live-for-${aud}`, token_type: 'Bearer', expires_in: 300 }));++ }++ if (req.url.startsWith('/internal/calls/')) {++ const r = { method: req.method, url: req.url, auth: req.headers.authorization || null, body: raw ? JSON.parse(raw) : null };++ seen.push(r);++ const [status, body] = answer(r);++ res.statusCode = status;++ return res.end(JSON.stringify(body));++ }++ res.statusCode = 404; res.end('{}');++ });++});++const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++(async () => {++ const port = await listen(stub);++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${port}`;++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${port}`;++++ const db = require('../server/db/database');++ db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });++ const calls = require('../server/streaming/calls-authority');++ const callServer = require('../server/streaming/call-server');++ calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);++++ let exit = 0;++ try {++ // 1. The flag.++ for (const v of [undefined, '', 'live', 'LIVE', 'other']) {++ if (v === undefined) delete process.env.CALLS_AUTHORITY; else process.env.CALLS_AUTHORITY = v;++ assert.strictEqual(calls.authority(), 'live', String(v));++ }++ for (const v of ['chat', 'CHAT', ' chat ']) { process.env.CALLS_AUTHORITY = v; assert.strictEqual(calls.authority(), 'chat', v); }++ delete process.env.CALLS_AUTHORITY;++++ // 2. Default: Live's call server, nothing sent.++ const ch = calls.createStreamChannel(41, 'mic', 7);++ assert.deepStrictEqual([ch.id, ch.mode, ch.name, ch.createdBy], ['stream-41', 'mic', 'Stream 41 title', 7]);++ assert.ok(callServer.channels.has('stream-41'));++ calls.removeStreamChannel(41);++ assert.ok(!callServer.channels.has('stream-41'));++ await sleep(50);++ assert.deepStrictEqual(seen, [], 'CALLS_AUTHORITY unset sends nothing to Chat');++++ // 3. chat: the hooks go to Chat's internal endpoints with Live's service token; nothing local.++ process.env.CALLS_AUTHORITY = 'chat';++ const created = await calls.createStreamChannel(42, 'mic+cam', 7);++ assert.deepStrictEqual(created, { ok: true });++ assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });++ assert.ok(tokenAudiences.includes('openvibe.chat'));++ assert.ok(!callServer.channels.has('stream-42'), 'Live\'s call server is not used');++ await calls.removeStreamChannel(42);++ assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);++++ // 4. A 5xx (or Chat down) is retried; a 4xx is not.++ seen.length = 0;++ let n = 0;++ answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);++ assert.deepStrictEqual(await calls.createStreamChannel(43, 'mic', 7), { ok: true });++ assert.strictEqual(seen.length, 2, 'retried once');++ seen.length = 0;++ answer = () => [409, { error: 'Calls are not served by Chat (CHAT_CALLS is not set)', code: 'calls.off' }];++ assert.strictEqual(await calls.createStreamChannel(43, 'mic', 7), null);++ assert.strictEqual(seen.length, 1, 'a refusal is not retried');++ assert.ok(/409/.test(calls.stats.lastError));++++ // 5. A retry that a later call for the same stream superseded is dropped: a stream that ended++ // while its go-live was being retried does not get its channel back.++ seen.length = 0;++ answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);++ const pending = calls.createStreamChannel(44, 'mic', 7);++ await sleep(10);++ await calls.removeStreamChannel(44);++ assert.strictEqual(await pending, null);++ await sleep(120);++ assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');++ assert.ok(calls.stats.superseded >= 1);++++ // 6. Every stream hook goes through the authority, not straight to Live's call server.++ const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');++ const routes = src('streaming/routes.js');++ assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');++ assert.ok(routes.includes('callsAuthority.removeStreamChannel(stream.id)'), 'stream end');++ for (const f of ['streaming/whip-handler.js', 'admin/routes.js']) {++ assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);++ assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);++ }++ assert.strictEqual((src('streaming/calls-authority.js').match(/process\.env\.CALLS_AUTHORITY/g) || []).length, 1);++ for (const f of ['index.js', 'streaming/routes.js', 'streaming/whip-handler.js', 'admin/routes.js']) {++ assert.ok(!src(f).includes('process.env.CALLS_AUTHORITY'), `${f} does not read the flag itself`);++ }++ } catch (err) {++ exit = 1;++ console.error(err);++ }++ stub.close();++ try { callServer.close(); } catch { /* */ }++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log = quiet;++ console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');++ process.exit(exit);++})();@@@ -207,6 +207,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);++ // 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.++ const notify = require('../server/utils/notify');++ const pushed = [];++ const origPush = notify.pushNotification;++ notify.pushNotification = (p) => pushed.push(p);++ try {++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: READ, body: { caller_id: streamer, target_id: viewer, channel_id: 'user-1-x' } })).status, 403, 'an effect');++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: 999999, channel_id: 'user-1-x' } })).status, 400);++ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: viewer, channel_id: `user-${streamer}-x`, channel_name: 'STREAMER\'s call' } })).status, 200);++ } finally { notify.pushNotification = origPush; }++ assert.strictEqual(pushed.length, 1);++ assert.deepStrictEqual([pushed[0].user_id, pushed[0].type, pushed[0].title, pushed[0].message, pushed[0].sender_id, pushed[0].rich_content.context.caller_username],++ [viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);++ assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));++// 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
@@ -158,6 +158,12 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
@@ -158,6 +158,12 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.
# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.
#CHAT_AUTHORITY=chat
#CHAT_AUTHORITY=chat
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
+# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;
+# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end
+# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat
+# bridge's service token) instead of Live's call server. Set it together with the nginx include that
+# sends /ws/call and the call routes to Chat (OpenVibe.Chat docs/calls-cutover.md). Unset = Live.
+#CALLS_AUTHORITY=chat
# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
@@ -88,7 +88,11 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -88,7 +88,11 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
- TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`).
+ TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). OpenVibe.Chat
+ runs the same protocol and routes (its `server/calls/`); `CALLS_AUTHORITY=chat`
+ (`server/streaming/calls-authority.js`) plus an nginx include moves calls there, and then go-live,
+ stream end, WHIP teardown and the admin force-end create/remove stream voice channels in Chat
+ (OpenVibe.Chat `docs/calls-cutover.md`).
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
@@ -344,7 +344,7 @@ router.delete('/streams/:id', (req, res) => {
@@ -344,7 +344,7 @@ router.delete('/streams/:id', (req, res) => {
try { require('../integrations/ai-chatbot-service').stopForStream(stream.id); } catch { /* non-critical */ }
try { require('../integrations/ai-chatbot-service').stopForStream(stream.id); } catch { /* non-critical */ }
try { require('../streaming/broadcast-server').endStream(stream.id); } catch { /* no room */ }
try { require('../streaming/broadcast-server').endStream(stream.id); } catch { /* no room */ }
try { require('../streaming/webrtc-sfu').closeRoom(`stream-${stream.id}`); } catch { /* no room */ }
try { require('../streaming/webrtc-sfu').closeRoom(`stream-${stream.id}`); } catch { /* no room */ }
- try { require('../streaming/call-server').removeStreamChannel(stream.id); } catch { /* no channel */ }
+ try { require('../streaming/calls-authority').removeStreamChannel(stream.id); } catch { /* no channel */ }
// Accountability: force-ending someone's live stream is a moderation action
// Accountability: force-ending someone's live stream is a moderation action
// and MUST be logged (previously it left no trace at all).
// and MUST be logged (previously it left no trace at all).
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
* /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
* alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
- * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync
+ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, notify/call-invite, asset-sync
* mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
* mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
*
*
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
* Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
@@ -553,6 +553,36 @@ effectsRouter.post('/notify/dm', (req, res) => {
@@ -553,6 +553,36 @@ effectsRouter.post('/notify/dm', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
+// A call-user ring from OpenVibe.Chat's call server (CALLS_AUTHORITY=chat): the cross-site
+// notification Live's POST /api/streams/voice-channels/call-user pushed itself.
+effectsRouter.post('/notify/call-invite', (req, res) => {
+ const caller = db.getUserById(int(req.body?.caller_id));
+ const targetId = int(req.body?.target_id);
+ const channelId = String(req.body?.channel_id || '').slice(0, 100);
+ const channelName = String(req.body?.channel_name || 'Voice Channel').slice(0, 100);
+ if (!caller || !targetId || !db.getUserById(targetId) || !channelId) return fail(res, 400, 'caller_id, target_id and channel_id required');
+ try {
+ const { pushNotification, actorInfo } = require('../utils/notify');
+ const callerName = caller.display_name || caller.username || 'Someone';
+ pushNotification({
+ user_id: targetId,
+ type: 'VC_CALL_INVITE',
+ title: `${callerName} is calling you`,
+ message: `Join voice channel: ${channelName}`,
+ url: `${config.baseUrl}/?vcInvite=${encodeURIComponent(channelId)}`,
+ rich_content: {
+ context: {
+ channel_id: channelId,
+ channel_name: channelName,
+ caller_username: caller.username,
+ },
+ },
+ ...actorInfo(caller, callerName),
+ });
+ } catch { /* non-critical */ }
+ res.json({ ok: true });
+});
+
effectsRouter.post('/notify/dm-read', (req, res) => {
effectsRouter.post('/notify/dm-read', (req, res) => {
try {
try {
const { markNotificationsRead } = require('../utils/notify');
const { markNotificationsRead } = require('../utils/notify');
@@ -1184,6 +1184,9 @@ async function start() {
@@ -1184,6 +1184,9 @@ async function start() {
// 4d. Initialize group call signaling server
// 4d. Initialize group call signaling server
callServer.init(server);
callServer.init(server);
+ if (require('./streaming/calls-authority').isChat()) {
+ console.log('[Calls] CALLS_AUTHORITY=chat — stream voice channels are created and removed in OpenVibe.Chat; Live\'s /ws/call stays up, unreached once nginx routes it to Chat');
+ }
for (const stream of db.getLiveStreams()) {
for (const stream of db.getLiveStreams()) {
robotStreamerService.startForStream(stream).catch((err) => {
robotStreamerService.startForStream(stream).catch((err) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * Who runs voice/video calls (roadmap WS-I task 1). CALLS_AUTHORITY is read here and nowhere else.
+ *
+ * live (default, anything but "chat"): Live's own call server (./call-server.js) serves /ws/call and
+ * the voice-channel routes, and the stream lifecycle creates and removes stream voice channels in
+ * it — exactly as before.
+ * chat: OpenVibe.Chat owns calls (nginx sends /ws/call, /api/streams/voice-channels… and
+ * /api/streams/:id/call there; Chat runs with CHAT_CALLS=1 — OpenVibe.Chat docs/calls-cutover.md).
+ * Go-live with a call mode, stream end, the WHIP teardown and the admin force-end ask Chat instead
+ * of the local call server:
+ * POST /internal/calls/stream-channel { stream_id, mode, user_id }
+ * DELETE /internal/calls/stream-channel/:streamId
+ * with Live's service token for audience openvibe.chat — the same principal and grant
+ * (chat.live_bridge.write) Live's chat bridge uses (../chat/chat-remote.js). Fire-and-forget for the
+ * caller: a network error or a 5xx is retried after 1 s, 5 s and 15 s, unless a later call for the
+ * same stream superseded it (a stream that ended while its go-live was being retried is not
+ * given a channel afterwards); a 4xx is logged and dropped. Live's /ws/call and voice-channel
+ * routes stay mounted for one release, unreached once nginx routes those paths to Chat.
+ */
+const { CHAT_URL } = require('../chat/chat-authority');
+
+const AUDIENCE = 'openvibe.chat';
+const RETRY_MS = [1000, 5000, 15000];
+const TIMEOUT_MS = 5000;
+
+function authority() {
+ return String(process.env.CALLS_AUTHORITY || '').trim().toLowerCase() === 'chat' ? 'chat' : 'live';
+}
+function isChat() { return authority() === 'chat'; }
+
+const local = () => require('./call-server');
+
+async function send(method, path, body, retried = false) {
+ const principal = require('../net/network-principal');
+ const res = await fetch(`${CHAT_URL}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await principal.serviceHeaders(AUDIENCE)) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return send(method, path, body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error(`Chat ${res.status}${data && data.error ? `: ${data.error}` : ''}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data;
+}
+
+const latest = new Map(); // streamId → seq of the newest call for it
+let seq = 0;
+const stats = { sent: 0, retried: 0, superseded: 0, failed: 0, lastError: null };
+
+/** Send one hook to Chat, retrying as above. Resolves (never rejects) with Chat's answer or null. */
+function deliver(streamId, method, path, body) {
+ const mine = ++seq;
+ latest.set(streamId, mine);
+ const attempt = async (n) => {
+ if (latest.get(streamId) !== mine) { stats.superseded++; return null; }
+ try {
+ const out = await send(method, path, body);
+ stats.sent++;
+ if (latest.get(streamId) === mine) latest.delete(streamId);
+ return out;
+ } catch (err) {
+ stats.lastError = err.message;
+ const retry = !(err.status >= 400 && err.status < 500) && n < RETRY_MS.length;
+ if (!retry) {
+ stats.failed++;
+ console.warn(`[Calls] Chat refused ${method} ${path}: ${err.message}`);
+ if (latest.get(streamId) === mine) latest.delete(streamId);
+ return null;
+ }
+ stats.retried++;
+ console.warn(`[Calls] ${method} ${path} failed (${err.message}); retrying in ${RETRY_MS[n] / 1000}s`);
+ await new Promise((r) => { const t = setTimeout(r, RETRY_MS[n]); if (t.unref) t.unref(); });
+ return attempt(n + 1);
+ }
+ };
+ return attempt(0);
+}
+
+/** Go-live with a call mode, or the mode changed: the stream's voice channel. */
+function createStreamChannel(streamId, mode, userId) {
+ if (!isChat()) return local().createStreamChannel(streamId, mode, userId);
+ return deliver(Number(streamId), 'POST', '/internal/calls/stream-channel', { stream_id: Number(streamId), mode, user_id: Number(userId) });
+}
+
+/** The stream ended (or was force-ended): its call ends and its voice channel goes. */
+function removeStreamChannel(streamId) {
+ if (!isChat()) return local().removeStreamChannel(streamId);
+ return deliver(Number(streamId), 'DELETE', `/internal/calls/stream-channel/${Number(streamId)}`);
+}
+
+module.exports = { authority, isChat, createStreamChannel, removeStreamChannel, stats, RETRY_MS, AUDIENCE };
@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');
@@ -35,6 +35,8 @@ const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
+// Stream voice channels: Live's call server, or OpenVibe.Chat's with CALLS_AUTHORITY=chat.
+const callsAuthority = require('./calls-authority');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const multer = require('multer');
const multer = require('multer');
const path = require('path');
const path = require('path');
@@ -2044,7 +2046,7 @@ router.post('/', requireAuth, (req, res) => {
@@ -2044,7 +2046,7 @@ router.post('/', requireAuth, (req, res) => {
// Set call mode if provided — create a stream voice channel
// Set call mode if provided — create a stream voice channel
if (callMode) {
if (callMode) {
db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [callMode, streamId]);
db.run('UPDATE streams SET call_mode = ? WHERE id = ?', [callMode, streamId]);
- callServer.createStreamChannel(streamId, callMode, req.user.id);
+ callsAuthority.createStreamChannel(streamId, callMode, req.user.id);
}
}
let endpoint = {};
let endpoint = {};
@@ -2179,7 +2181,7 @@ router.delete('/:id', requireAuth, (req, res) => {
@@ -2179,7 +2181,7 @@ router.delete('/:id', requireAuth, (req, res) => {
}
}
// End any active group call / remove stream voice channel
// End any active group call / remove stream voice channel
- callServer.removeStreamChannel(stream.id);
+ callsAuthority.removeStreamChannel(stream.id);
robotStreamerService.stopForStream(stream.id);
robotStreamerService.stopForStream(stream.id);
chatRelayService.stopForStream(stream.id);
chatRelayService.stopForStream(stream.id);
@@ -203,7 +203,7 @@ function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
@@ -203,7 +203,7 @@ function endActiveWhipStream(streamId, reason = 'whip_cleanup') {
try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('../integrations/ai-chatbot-service').stopForStream(streamId); } catch (err) { /* ignore */ }
try { require('./broadcast-server').endStream(streamId); } catch (err) { /* ignore */ }
try { require('./broadcast-server').endStream(streamId); } catch (err) { /* ignore */ }
try { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }
try { webrtcSFU.closeRoom(`stream-${streamId}`); } catch (err) { /* ignore */ }
- try { require('./call-server').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
+ try { require('./calls-authority').removeStreamChannel(streamId); } catch (err) { /* ignore */ }
}
}
// ── SDP Parsing Utilities ────────────────────────────────────
// ── SDP Parsing Utilities ────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+
+// Who runs calls (WS-I task 1, server/streaming/calls-authority.js). CALLS_AUTHORITY unset (or
+// anything but "chat"): stream voice channels are made and removed by Live's own call server and
+// nothing is sent anywhere — as before. "chat": the same hooks become POST/DELETE
+// /internal/calls/stream-channel on OpenVibe.Chat with Live's service token for audience
+// openvibe.chat, retried after a network error or a 5xx, never after a 4xx, and a retry that a later
+// call for the same stream superseded is dropped. The go-live, stream-end, WHIP and admin force-end
+// hooks all go through it. Against a stub Network + Chat.
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-calls-authority-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.OV_OAUTH_CLIENT_ID = 'live';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+delete process.env.CALLS_AUTHORITY;
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+const seen = []; // requests to Chat's /internal/calls
+const tokenAudiences = [];
+let answer = () => [200, { ok: true }];
+const stub = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') {
+ const aud = new URLSearchParams(raw).get('audience');
+ tokenAudiences.push(aud);
+ return res.end(JSON.stringify({ access_token: `svc-live-for-${aud}`, token_type: 'Bearer', expires_in: 300 }));
+ }
+ if (req.url.startsWith('/internal/calls/')) {
+ const r = { method: req.method, url: req.url, auth: req.headers.authorization || null, body: raw ? JSON.parse(raw) : null };
+ seen.push(r);
+ const [status, body] = answer(r);
+ res.statusCode = status;
+ return res.end(JSON.stringify(body));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+});
+const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+(async () => {
+ const port = await listen(stub);
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${port}`;
+ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${port}`;
+
+ const db = require('../server/db/database');
+ db.getStreamById = (id) => ({ id: Number(id), user_id: 7, title: `Stream ${id} title`, is_live: 1 });
+ const calls = require('../server/streaming/calls-authority');
+ const callServer = require('../server/streaming/call-server');
+ calls.RETRY_MS.splice(0, calls.RETRY_MS.length, 30, 30, 30);
+
+ let exit = 0;
+ try {
+ // 1. The flag.
+ for (const v of [undefined, '', 'live', 'LIVE', 'other']) {
+ if (v === undefined) delete process.env.CALLS_AUTHORITY; else process.env.CALLS_AUTHORITY = v;
+ assert.strictEqual(calls.authority(), 'live', String(v));
+ }
+ for (const v of ['chat', 'CHAT', ' chat ']) { process.env.CALLS_AUTHORITY = v; assert.strictEqual(calls.authority(), 'chat', v); }
+ delete process.env.CALLS_AUTHORITY;
+
+ // 2. Default: Live's call server, nothing sent.
+ const ch = calls.createStreamChannel(41, 'mic', 7);
+ assert.deepStrictEqual([ch.id, ch.mode, ch.name, ch.createdBy], ['stream-41', 'mic', 'Stream 41 title', 7]);
+ assert.ok(callServer.channels.has('stream-41'));
+ calls.removeStreamChannel(41);
+ assert.ok(!callServer.channels.has('stream-41'));
+ await sleep(50);
+ assert.deepStrictEqual(seen, [], 'CALLS_AUTHORITY unset sends nothing to Chat');
+
+ // 3. chat: the hooks go to Chat's internal endpoints with Live's service token; nothing local.
+ process.env.CALLS_AUTHORITY = 'chat';
+ const created = await calls.createStreamChannel(42, 'mic+cam', 7);
+ assert.deepStrictEqual(created, { ok: true });
+ assert.deepStrictEqual(seen[0], { method: 'POST', url: '/internal/calls/stream-channel', auth: 'Bearer svc-live-for-openvibe.chat', body: { stream_id: 42, mode: 'mic+cam', user_id: 7 } });
+ assert.ok(tokenAudiences.includes('openvibe.chat'));
+ assert.ok(!callServer.channels.has('stream-42'), 'Live\'s call server is not used');
+ await calls.removeStreamChannel(42);
+ assert.deepStrictEqual([seen[1].method, seen[1].url, seen[1].body], ['DELETE', '/internal/calls/stream-channel/42', null]);
+
+ // 4. A 5xx (or Chat down) is retried; a 4xx is not.
+ seen.length = 0;
+ let n = 0;
+ answer = () => (++n === 1 ? [503, { error: 'restarting' }] : [200, { ok: true }]);
+ assert.deepStrictEqual(await calls.createStreamChannel(43, 'mic', 7), { ok: true });
+ assert.strictEqual(seen.length, 2, 'retried once');
+ seen.length = 0;
+ answer = () => [409, { error: 'Calls are not served by Chat (CHAT_CALLS is not set)', code: 'calls.off' }];
+ assert.strictEqual(await calls.createStreamChannel(43, 'mic', 7), null);
+ assert.strictEqual(seen.length, 1, 'a refusal is not retried');
+ assert.ok(/409/.test(calls.stats.lastError));
+
+ // 5. A retry that a later call for the same stream superseded is dropped: a stream that ended
+ // while its go-live was being retried does not get its channel back.
+ seen.length = 0;
+ answer = (r) => (r.method === 'POST' ? [502, { error: 'bad gateway' }] : [200, { ok: true, removed: false }]);
+ const pending = calls.createStreamChannel(44, 'mic', 7);
+ await sleep(10);
+ await calls.removeStreamChannel(44);
+ assert.strictEqual(await pending, null);
+ await sleep(120);
+ assert.deepStrictEqual(seen.map((r) => r.method), ['POST', 'DELETE'], 'the go-live was not retried after the stream ended');
+ assert.ok(calls.stats.superseded >= 1);
+
+ // 6. Every stream hook goes through the authority, not straight to Live's call server.
+ const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'server', f), 'utf8');
+ const routes = src('streaming/routes.js');
+ assert.ok(routes.includes('callsAuthority.createStreamChannel(streamId, callMode, req.user.id)'), 'go-live');
+ assert.ok(routes.includes('callsAuthority.removeStreamChannel(stream.id)'), 'stream end');
+ for (const f of ['streaming/whip-handler.js', 'admin/routes.js']) {
+ assert.ok(/calls-authority'\)\.removeStreamChannel\(/.test(src(f)), f);
+ assert.ok(!/call-server'\)\.removeStreamChannel\(/.test(src(f)), `${f} no longer calls Live's call server directly`);
+ }
+ assert.strictEqual((src('streaming/calls-authority.js').match(/process\.env\.CALLS_AUTHORITY/g) || []).length, 1);
+ for (const f of ['index.js', 'streaming/routes.js', 'streaming/whip-handler.js', 'admin/routes.js']) {
+ assert.ok(!src(f).includes('process.env.CALLS_AUTHORITY'), `${f} does not read the flag itself`);
+ }
+ } catch (err) {
+ exit = 1;
+ console.error(err);
+ }
+ stub.close();
+ try { callServer.close(); } catch { /* */ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log = quiet;
+ console.log(exit ? 'calls authority: FAILED' : 'calls authority: all checks passed');
+ process.exit(exit);
+})();
@@ -207,6 +207,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -207,6 +207,21 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
+ // 8b. A ring from Chat's call server (CALLS_AUTHORITY=chat): Live pushes the VC_CALL_INVITE it used to.
+ const notify = require('../server/utils/notify');
+ const pushed = [];
+ const origPush = notify.pushNotification;
+ notify.pushNotification = (p) => pushed.push(p);
+ try {
+ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: READ, body: { caller_id: streamer, target_id: viewer, channel_id: 'user-1-x' } })).status, 403, 'an effect');
+ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: 999999, channel_id: 'user-1-x' } })).status, 400);
+ assert.strictEqual((await call('POST', '/internal/chat-effects/notify/call-invite', { token: WRITE, body: { caller_id: streamer, target_id: viewer, channel_id: `user-${streamer}-x`, channel_name: 'STREAMER\'s call' } })).status, 200);
+ } finally { notify.pushNotification = origPush; }
+ assert.strictEqual(pushed.length, 1);
+ assert.deepStrictEqual([pushed[0].user_id, pushed[0].type, pushed[0].title, pushed[0].message, pushed[0].sender_id, pushed[0].rich_content.context.caller_username],
+ [viewer, 'VC_CALL_INVITE', 'STREAMER is calling you', 'Join voice channel: STREAMER\'s call', streamer, 'streamer']);
+ assert.ok(pushed[0].url.endsWith(`/?vcInvite=${encodeURIComponent(`user-${streamer}-x`)}`));
+
// 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.
// 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.
const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');