openvibers/openvibe.live Commits
Show agent chat and supply recorded steps to Video (#809)
Changes
@@@ -8,6 +8,11 @@.agent-task a { color: var(--accent); }.agent-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }@media (min-width: 900px) { .agent-layout { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; } }++.agent-chat { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--bg-secondary); }++.agent-chat[hidden] { display: none; }++.agent-chat h2 { margin: 0; padding: 10px 14px; font-size: 1rem; }++.agent-chat iframe { display: block; width: 100%; height: 420px; border: 0; }++@media (min-width: 900px) { .agent-main { grid-column: 1; grid-row: 1 / span 2; } .agent-chat { grid-column: 2; grid-row: 1; } .agent-side { grid-column: 2; grid-row: 2; } .agent-layout:has(.agent-chat[hidden]) .agent-side { grid-row: 1; } }.agent-main { min-width: 0; }.agent-stage { position: relative; aspect-ratio: 16 / 9; width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #0b0d10; }.agent-stage video, .agent-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #0b0d10; }@@@ -47,6 +47,10 @@</div></section></div>++ <aside class="agent-chat" id="agent-chat" hidden aria-label="Agent stream chat">++ <h2>Chat</h2>++ <iframe id="agent-chat-frame" title="Agent stream chat" loading="lazy" referrerpolicy="no-referrer"></iframe>++ </aside><aside class="agent-side" id="agent-owner" hidden><h2 class="agent-side-title">This stream</h2><div class="agent-field">@@@ -365,6 +365,7 @@mountPlays(data);ensureOverlay(data);renderOwner(data);++ renderChat(stream);renderControls(data);renderVoice();loadVoices();@@@ -376,6 +377,23 @@if (stream.state === 'ended') renderRecording(data);}++ function renderChat(stream) {++ const panel = $('agent-chat');++ const frame = $('agent-chat-frame');++ if (!panel || !frame) return;++ if (!stream || stream.state === 'ended' || !stream.chat_room || !['public', 'unlisted'].includes(stream.visibility)) {++ panel.hidden = true;++ frame.removeAttribute('src');++ return;++ }++ let secret = new URLSearchParams(location.search).get('k') || '';++ try { secret = new URL(stream.page_url, location.origin).searchParams.get('k') || secret; } catch { /* current link remains */ }++ if (stream.visibility === 'unlisted' && !/^[A-Za-z0-9_-]{22}$/.test(secret)) { panel.hidden = true; return; }++ const url = `https://openvibe.chat/embed/agent/${encodeURIComponent(stream.id)}${stream.visibility === 'unlisted' ? `?k=${encodeURIComponent(secret)}` : ''}`;++ if (frame.getAttribute('src') !== url) frame.setAttribute('src', url);++ panel.hidden = false;++ }++/** S5: the moderators' bar. The owner and the channel's moderators see it; the owner also sees the log. */function renderControls(data) {const bar = $('agent-controls');@@@ -579,6 +597,7 @@if (next && next.stream && state) {state.stream = next.stream;renderOwner(state);++ renderChat(state.stream);}} catch (err) {select.value = state ? state.stream.visibility : 'private';@@@ -722,6 +741,7 @@if (state && next && next.stream) {state.stream = next.stream;renderOwner(state);++ renderChat(state.stream);}if (next && next.stream && next.stream.page_url && navigator.clipboard) {await navigator.clipboard.writeText(next.stream.page_url).catch(() => {});@@@ -337,6 +337,16 @@ router.get('/mine', auth.viewer, async (req, res) => {}});++// Chat checks this for each room read/post. It is deliberately ticket-free, so++// rotating an unlisted link or making the stream private revokes chat at once.++router.get('/:id/chat-access', async (req, res) => {++ const row = await service.getByPublicId(req.params.id);++ const secret = typeof req.query.k === 'string' ? req.query.k : null;++ if (!row || !['public', 'unlisted'].includes(row.visibility) ||++ !await service.mayView(row, { secret })) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');++ res.set('Cache-Control', 'no-store').json({ visibility: row.visibility });++});++router.get('/:id', auth.viewer, async (req, res) => {try {const row = await load(req, res);@@@ -402,11 +402,13 @@ async function setVisibility(row, visibility) {* stream becomes visible. A private stream has none. Fire-and-forget: Chat being down never breaks the stream.*/function syncChatRoom(row) {-- if (!row || !['public', 'unlisted'].includes(row.visibility)) return;++ if (!row) return;try {const chat = require('../chat/chat-client');if (!chat.available()) return;-- chat.agentRoom({ agent_stream_id: row.public_id, owner_subject: row.owner_subject || null, name: row.title })++ chat.agentRoom({ agent_stream_id: row.public_id, owner_subject: row.owner_subject || null,++ owner_username: row.username, name: row.title, visibility: row.visibility,++ link_secret_hash: row.visibility === 'unlisted' ? row.unlisted_secret_hash : null }).catch(() => { /* logged by the client */ });} catch { /* Chat is optional for a stream */ }}@@@ -424,7 +426,8 @@ function closeChatRoom(row) {/** A fresh unlisted link secret: the old links stop working at once. */async function rotateLink(row) {const secret = linkSecret();-- await update(row, { set: { unlisted_secret_hash: sha256(secret) } });++ const next = await update(row, { set: { unlisted_secret_hash: sha256(secret) } });++ syncChatRoom(next);return secret;}@@@ -588,6 +588,29 @@ router.get('/:id/context', optionalAuth, async (req, res) => {}});++// The recording's agent overlay, in VOD time. The same visibility check as the++// recording keeps private steps out of public watch pages.++router.get('/:id/agent-steps', optionalAuth, async (req, res) => {++ try {++ const vod = await loadVisibleVod(req, res, req.params.id);++ if (!vod) return;++ const row = await db.get('SELECT a.*, m.public_id, m.title FROM agent_streams a JOIN managed_streams m ON m.id = a.managed_stream_id WHERE a.vod_id = ?', [String(vod.id)]);++ if (!row) return res.status(404).json({ error: 'No agent steps for this recording' });++ const { stepView } = require('../agent-streams/steps');++ const rows = await db.all('SELECT * FROM agent_stream_steps WHERE managed_stream_id = ? ORDER BY at ASC, id ASC LIMIT 2000', [row.managed_stream_id]);++ const started = row.started_at ? new Date(row.started_at).getTime() : NaN;++ const part = await require('../streaming/stream-history').partForVod(vod.id).catch(() => null);++ const offset = Number(part && part.at) || 0;++ const steps = rows.map((r) => {++ const step = stepView(r);++ return { ...step, offset_seconds: Math.max(0, (new Date(step.at).getTime() - started) / 1000 - offset) };++ }).filter((step) => Number.isFinite(step.offset_seconds));++ if (access.isPrivate(vod)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const task = typeof row.task === 'string' ? JSON.parse(row.task) : row.task;++ res.json({ task: task && task.title || row.title || vod.title, stream_id: row.public_id, steps });++ } catch (err) { mediaErr(res, err, 'Agent steps unavailable'); }++});++router.get('/:id', optionalAuth, async (req, res) => {try {// Fetched with the app's authority (no acting user): Live decides who sees what, so staff@@@ -71,6 +71,7 @@ async function check(name, fn) {app.use(express.json());app.use((req, res, next) => { req.user = { 1: ann, 2: bob }[req.get('x-test-user')] || null; next(); });app.use('/api/streams/agent', require('../server/agent-streams/routes'));++ app.use('/api/vods', require('../server/media-proxy/vods'));const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });const base = `http://127.0.0.1:${server.address().port}`;const call = async (method, url, { as = 1, body, headers = {} } = {}) => {@@@ -86,6 +87,16 @@ async function check(name, fn) {};const slack = async () => await db.run("UPDATE agent_stream_steps SET created_at = now() - interval '2 seconds'");++ await check('Chat access follows the live link without minting a view ticket', async () => {++ assert.strictEqual((await call('GET', `/api/streams/agent/${publicStream.public_id}/chat-access`, { as: 0 })).status, 200);++ assert.strictEqual((await call('GET', `/api/streams/agent/${privateStream.public_id}/chat-access`, { as: 0 })).status, 404);++ const link = await streamOf('unlisted', 'Link only');++ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access`, { as: 0 })).status, 404);++ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access?k=${link.unlisted_secret}`, { as: 0 })).status, 200);++ await service.rotateLink(link);++ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access?k=${link.unlisted_secret}`, { as: 0 })).status, 404);++ });++quiet('agent streams S2: the steps');const publish = await tickets.issuePublish(publicStream.managed_stream_id);await service.markPublishing(publicStream);@@@ -194,6 +205,36 @@ async function check(name, fn) {assert.deepStrictEqual(r.data.chapters.map((c) => c.title), ['Install dependencies', 'Run the tests']);assert.strictEqual(r.data.vod_id, '512');});++ await check('the VOD step feed keeps the recording visibility and returns playback offsets', async () => {++ const media = require('../server/media-client');++ const original = media.getVod;++ media.getVod = async () => ({ id: 512, user_id: ann.id, visibility: 'public', duration_seconds: 300 });++ try {++ const r = await call('GET', '/api/vods/512/agent-steps', { as: 2 });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.deepStrictEqual(r.data.steps.filter((s) => s.status === 'started').map((s) => Math.round(s.offset_seconds)), [60, 180]);++ media.getVod = async () => ({ id: 512, user_id: ann.id, visibility: 'private', duration_seconds: 300 });++ assert.strictEqual((await call('GET', '/api/vods/512/agent-steps', { as: 2 })).status, 404);++ } finally { media.getVod = original; }++ });++ await check('Chat receives the stream visibility and rotated unlisted link hash', async () => {++ const chat = require('../server/chat/chat-client');++ const available = chat.available;++ const send = chat.agentRoom;++ const delivered = [];++ chat.available = () => true;++ chat.agentRoom = async (body) => { delivered.push(body); return { ok: true }; };++ try {++ const row = await streamOf('private', 'Chat visibility');++ const unlisted = await service.setVisibility(row, 'unlisted');++ assert.ok(delivered.some((x) => x.visibility === 'unlisted' && x.link_secret_hash === unlisted.unlisted_secret_hash));++ const old = unlisted.unlisted_secret_hash;++ await service.rotateLink(unlisted);++ assert.ok(delivered.some((x) => x.visibility === 'unlisted' && x.link_secret_hash !== old));++ await service.setVisibility(await service.getByManagedStreamId(row.managed_stream_id), 'private');++ assert.ok(delivered.some((x) => x.visibility === 'private' && x.link_secret_hash === null));++ } finally { chat.available = available; chat.agentRoom = send; }++ });await check('a failed step and the final passed step can be clipped, through Media', async () => {const media = require('../server/media-client');const original = media.createClip;@@@ -99,10 +99,10 @@ async function check(name, fn) {assert.strictEqual(r.status, 200, JSON.stringify(r.data));});-- await check('the room is made for a public or unlisted stream, never a private one', async () => {++ await check('Chat receives public and unlisted rooms plus a private visibility marker', async () => {assert.ok(roomCalls.some((c) => c.agent_stream_id === publicStream.public_id));assert.ok(roomCalls.some((c) => c.agent_stream_id === deskStream.public_id));-- assert.ok(!roomCalls.some((c) => c.agent_stream_id === privateStream.public_id));++ assert.ok(roomCalls.some((c) => c.agent_stream_id === privateStream.public_id && c.visibility === 'private'), 'Chat hides an old room when its stream becomes private');assert.strictEqual(roomCalls.find((c) => c.agent_stream_id === publicStream.public_id).owner_subject, SID.ann);});
@@ -8,6 +8,11 @@
@@ -8,6 +8,11 @@
.agent-task a { color: var(--accent); }
.agent-task a { color: var(--accent); }
.agent-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
.agent-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
@media (min-width: 900px) { .agent-layout { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; } }
@media (min-width: 900px) { .agent-layout { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; } }
+.agent-chat { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--bg-secondary); }
+.agent-chat[hidden] { display: none; }
+.agent-chat h2 { margin: 0; padding: 10px 14px; font-size: 1rem; }
+.agent-chat iframe { display: block; width: 100%; height: 420px; border: 0; }
+@media (min-width: 900px) { .agent-main { grid-column: 1; grid-row: 1 / span 2; } .agent-chat { grid-column: 2; grid-row: 1; } .agent-side { grid-column: 2; grid-row: 2; } .agent-layout:has(.agent-chat[hidden]) .agent-side { grid-row: 1; } }
.agent-main { min-width: 0; }
.agent-main { min-width: 0; }
.agent-stage { position: relative; aspect-ratio: 16 / 9; width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #0b0d10; }
.agent-stage { position: relative; aspect-ratio: 16 / 9; width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #0b0d10; }
.agent-stage video, .agent-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #0b0d10; }
.agent-stage video, .agent-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #0b0d10; }
@@ -47,6 +47,10 @@
@@ -47,6 +47,10 @@
</div>
</div>
</section>
</section>
</div>
</div>
+ <aside class="agent-chat" id="agent-chat" hidden aria-label="Agent stream chat">
+ <h2>Chat</h2>
+ <iframe id="agent-chat-frame" title="Agent stream chat" loading="lazy" referrerpolicy="no-referrer"></iframe>
+ </aside>
<aside class="agent-side" id="agent-owner" hidden>
<aside class="agent-side" id="agent-owner" hidden>
<h2 class="agent-side-title">This stream</h2>
<h2 class="agent-side-title">This stream</h2>
<div class="agent-field">
<div class="agent-field">
@@ -365,6 +365,7 @@
@@ -365,6 +365,7 @@
mountPlays(data);
mountPlays(data);
ensureOverlay(data);
ensureOverlay(data);
renderOwner(data);
renderOwner(data);
+ renderChat(stream);
renderControls(data);
renderControls(data);
renderVoice();
renderVoice();
loadVoices();
loadVoices();
@@ -376,6 +377,23 @@
@@ -376,6 +377,23 @@
if (stream.state === 'ended') renderRecording(data);
if (stream.state === 'ended') renderRecording(data);
}
}
+ function renderChat(stream) {
+ const panel = $('agent-chat');
+ const frame = $('agent-chat-frame');
+ if (!panel || !frame) return;
+ if (!stream || stream.state === 'ended' || !stream.chat_room || !['public', 'unlisted'].includes(stream.visibility)) {
+ panel.hidden = true;
+ frame.removeAttribute('src');
+ return;
+ }
+ let secret = new URLSearchParams(location.search).get('k') || '';
+ try { secret = new URL(stream.page_url, location.origin).searchParams.get('k') || secret; } catch { /* current link remains */ }
+ if (stream.visibility === 'unlisted' && !/^[A-Za-z0-9_-]{22}$/.test(secret)) { panel.hidden = true; return; }
+ const url = `https://openvibe.chat/embed/agent/${encodeURIComponent(stream.id)}${stream.visibility === 'unlisted' ? `?k=${encodeURIComponent(secret)}` : ''}`;
+ if (frame.getAttribute('src') !== url) frame.setAttribute('src', url);
+ panel.hidden = false;
+ }
+
/** S5: the moderators' bar. The owner and the channel's moderators see it; the owner also sees the log. */
/** S5: the moderators' bar. The owner and the channel's moderators see it; the owner also sees the log. */
function renderControls(data) {
function renderControls(data) {
const bar = $('agent-controls');
const bar = $('agent-controls');
@@ -579,6 +597,7 @@
@@ -579,6 +597,7 @@
if (next && next.stream && state) {
if (next && next.stream && state) {
state.stream = next.stream;
state.stream = next.stream;
renderOwner(state);
renderOwner(state);
+ renderChat(state.stream);
}
}
} catch (err) {
} catch (err) {
select.value = state ? state.stream.visibility : 'private';
select.value = state ? state.stream.visibility : 'private';
@@ -722,6 +741,7 @@
@@ -722,6 +741,7 @@
if (state && next && next.stream) {
if (state && next && next.stream) {
state.stream = next.stream;
state.stream = next.stream;
renderOwner(state);
renderOwner(state);
+ renderChat(state.stream);
}
}
if (next && next.stream && next.stream.page_url && navigator.clipboard) {
if (next && next.stream && next.stream.page_url && navigator.clipboard) {
await navigator.clipboard.writeText(next.stream.page_url).catch(() => {});
await navigator.clipboard.writeText(next.stream.page_url).catch(() => {});
@@ -337,6 +337,16 @@ router.get('/mine', auth.viewer, async (req, res) => {
@@ -337,6 +337,16 @@ router.get('/mine', auth.viewer, async (req, res) => {
}
}
});
});
+// Chat checks this for each room read/post. It is deliberately ticket-free, so
+// rotating an unlisted link or making the stream private revokes chat at once.
+router.get('/:id/chat-access', async (req, res) => {
+ const row = await service.getByPublicId(req.params.id);
+ const secret = typeof req.query.k === 'string' ? req.query.k : null;
+ if (!row || !['public', 'unlisted'].includes(row.visibility) ||
+ !await service.mayView(row, { secret })) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
+ res.set('Cache-Control', 'no-store').json({ visibility: row.visibility });
+});
+
router.get('/:id', auth.viewer, async (req, res) => {
router.get('/:id', auth.viewer, async (req, res) => {
try {
try {
const row = await load(req, res);
const row = await load(req, res);
@@ -402,11 +402,13 @@ async function setVisibility(row, visibility) {
@@ -402,11 +402,13 @@ async function setVisibility(row, visibility) {
* stream becomes visible. A private stream has none. Fire-and-forget: Chat being down never breaks the stream.
* stream becomes visible. A private stream has none. Fire-and-forget: Chat being down never breaks the stream.
*/
*/
function syncChatRoom(row) {
function syncChatRoom(row) {
- if (!row || !['public', 'unlisted'].includes(row.visibility)) return;
+ if (!row) return;
try {
try {
const chat = require('../chat/chat-client');
const chat = require('../chat/chat-client');
if (!chat.available()) return;
if (!chat.available()) return;
- chat.agentRoom({ agent_stream_id: row.public_id, owner_subject: row.owner_subject || null, name: row.title })
+ chat.agentRoom({ agent_stream_id: row.public_id, owner_subject: row.owner_subject || null,
+ owner_username: row.username, name: row.title, visibility: row.visibility,
+ link_secret_hash: row.visibility === 'unlisted' ? row.unlisted_secret_hash : null })
.catch(() => { /* logged by the client */ });
.catch(() => { /* logged by the client */ });
} catch { /* Chat is optional for a stream */ }
} catch { /* Chat is optional for a stream */ }
}
}
@@ -424,7 +426,8 @@ function closeChatRoom(row) {
@@ -424,7 +426,8 @@ function closeChatRoom(row) {
/** A fresh unlisted link secret: the old links stop working at once. */
/** A fresh unlisted link secret: the old links stop working at once. */
async function rotateLink(row) {
async function rotateLink(row) {
const secret = linkSecret();
const secret = linkSecret();
- await update(row, { set: { unlisted_secret_hash: sha256(secret) } });
+ const next = await update(row, { set: { unlisted_secret_hash: sha256(secret) } });
+ syncChatRoom(next);
return secret;
return secret;
}
}
@@ -588,6 +588,29 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -588,6 +588,29 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
}
}
});
});
+// The recording's agent overlay, in VOD time. The same visibility check as the
+// recording keeps private steps out of public watch pages.
+router.get('/:id/agent-steps', optionalAuth, async (req, res) => {
+ try {
+ const vod = await loadVisibleVod(req, res, req.params.id);
+ if (!vod) return;
+ const row = await db.get('SELECT a.*, m.public_id, m.title FROM agent_streams a JOIN managed_streams m ON m.id = a.managed_stream_id WHERE a.vod_id = ?', [String(vod.id)]);
+ if (!row) return res.status(404).json({ error: 'No agent steps for this recording' });
+ const { stepView } = require('../agent-streams/steps');
+ const rows = await db.all('SELECT * FROM agent_stream_steps WHERE managed_stream_id = ? ORDER BY at ASC, id ASC LIMIT 2000', [row.managed_stream_id]);
+ const started = row.started_at ? new Date(row.started_at).getTime() : NaN;
+ const part = await require('../streaming/stream-history').partForVod(vod.id).catch(() => null);
+ const offset = Number(part && part.at) || 0;
+ const steps = rows.map((r) => {
+ const step = stepView(r);
+ return { ...step, offset_seconds: Math.max(0, (new Date(step.at).getTime() - started) / 1000 - offset) };
+ }).filter((step) => Number.isFinite(step.offset_seconds));
+ if (access.isPrivate(vod)) res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const task = typeof row.task === 'string' ? JSON.parse(row.task) : row.task;
+ res.json({ task: task && task.title || row.title || vod.title, stream_id: row.public_id, steps });
+ } catch (err) { mediaErr(res, err, 'Agent steps unavailable'); }
+});
+
router.get('/:id', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
// Fetched with the app's authority (no acting user): Live decides who sees what, so staff
// Fetched with the app's authority (no acting user): Live decides who sees what, so staff
@@ -71,6 +71,7 @@ async function check(name, fn) {
@@ -71,6 +71,7 @@ async function check(name, fn) {
app.use(express.json());
app.use(express.json());
app.use((req, res, next) => { req.user = { 1: ann, 2: bob }[req.get('x-test-user')] || null; next(); });
app.use((req, res, next) => { req.user = { 1: ann, 2: bob }[req.get('x-test-user')] || null; next(); });
app.use('/api/streams/agent', require('../server/agent-streams/routes'));
app.use('/api/streams/agent', require('../server/agent-streams/routes'));
+ app.use('/api/vods', require('../server/media-proxy/vods'));
const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
const call = async (method, url, { as = 1, body, headers = {} } = {}) => {
const call = async (method, url, { as = 1, body, headers = {} } = {}) => {
@@ -86,6 +87,16 @@ async function check(name, fn) {
@@ -86,6 +87,16 @@ async function check(name, fn) {
};
};
const slack = async () => await db.run("UPDATE agent_stream_steps SET created_at = now() - interval '2 seconds'");
const slack = async () => await db.run("UPDATE agent_stream_steps SET created_at = now() - interval '2 seconds'");
+ await check('Chat access follows the live link without minting a view ticket', async () => {
+ assert.strictEqual((await call('GET', `/api/streams/agent/${publicStream.public_id}/chat-access`, { as: 0 })).status, 200);
+ assert.strictEqual((await call('GET', `/api/streams/agent/${privateStream.public_id}/chat-access`, { as: 0 })).status, 404);
+ const link = await streamOf('unlisted', 'Link only');
+ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access`, { as: 0 })).status, 404);
+ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access?k=${link.unlisted_secret}`, { as: 0 })).status, 200);
+ await service.rotateLink(link);
+ assert.strictEqual((await call('GET', `/api/streams/agent/${link.public_id}/chat-access?k=${link.unlisted_secret}`, { as: 0 })).status, 404);
+ });
+
quiet('agent streams S2: the steps');
quiet('agent streams S2: the steps');
const publish = await tickets.issuePublish(publicStream.managed_stream_id);
const publish = await tickets.issuePublish(publicStream.managed_stream_id);
await service.markPublishing(publicStream);
await service.markPublishing(publicStream);
@@ -194,6 +205,36 @@ async function check(name, fn) {
@@ -194,6 +205,36 @@ async function check(name, fn) {
assert.deepStrictEqual(r.data.chapters.map((c) => c.title), ['Install dependencies', 'Run the tests']);
assert.deepStrictEqual(r.data.chapters.map((c) => c.title), ['Install dependencies', 'Run the tests']);
assert.strictEqual(r.data.vod_id, '512');
assert.strictEqual(r.data.vod_id, '512');
});
});
+ await check('the VOD step feed keeps the recording visibility and returns playback offsets', async () => {
+ const media = require('../server/media-client');
+ const original = media.getVod;
+ media.getVod = async () => ({ id: 512, user_id: ann.id, visibility: 'public', duration_seconds: 300 });
+ try {
+ const r = await call('GET', '/api/vods/512/agent-steps', { as: 2 });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.deepStrictEqual(r.data.steps.filter((s) => s.status === 'started').map((s) => Math.round(s.offset_seconds)), [60, 180]);
+ media.getVod = async () => ({ id: 512, user_id: ann.id, visibility: 'private', duration_seconds: 300 });
+ assert.strictEqual((await call('GET', '/api/vods/512/agent-steps', { as: 2 })).status, 404);
+ } finally { media.getVod = original; }
+ });
+ await check('Chat receives the stream visibility and rotated unlisted link hash', async () => {
+ const chat = require('../server/chat/chat-client');
+ const available = chat.available;
+ const send = chat.agentRoom;
+ const delivered = [];
+ chat.available = () => true;
+ chat.agentRoom = async (body) => { delivered.push(body); return { ok: true }; };
+ try {
+ const row = await streamOf('private', 'Chat visibility');
+ const unlisted = await service.setVisibility(row, 'unlisted');
+ assert.ok(delivered.some((x) => x.visibility === 'unlisted' && x.link_secret_hash === unlisted.unlisted_secret_hash));
+ const old = unlisted.unlisted_secret_hash;
+ await service.rotateLink(unlisted);
+ assert.ok(delivered.some((x) => x.visibility === 'unlisted' && x.link_secret_hash !== old));
+ await service.setVisibility(await service.getByManagedStreamId(row.managed_stream_id), 'private');
+ assert.ok(delivered.some((x) => x.visibility === 'private' && x.link_secret_hash === null));
+ } finally { chat.available = available; chat.agentRoom = send; }
+ });
await check('a failed step and the final passed step can be clipped, through Media', async () => {
await check('a failed step and the final passed step can be clipped, through Media', async () => {
const media = require('../server/media-client');
const media = require('../server/media-client');
const original = media.createClip;
const original = media.createClip;
@@ -99,10 +99,10 @@ async function check(name, fn) {
@@ -99,10 +99,10 @@ async function check(name, fn) {
assert.strictEqual(r.status, 200, JSON.stringify(r.data));
assert.strictEqual(r.status, 200, JSON.stringify(r.data));
});
});
- await check('the room is made for a public or unlisted stream, never a private one', async () => {
+ await check('Chat receives public and unlisted rooms plus a private visibility marker', async () => {
assert.ok(roomCalls.some((c) => c.agent_stream_id === publicStream.public_id));
assert.ok(roomCalls.some((c) => c.agent_stream_id === publicStream.public_id));
assert.ok(roomCalls.some((c) => c.agent_stream_id === deskStream.public_id));
assert.ok(roomCalls.some((c) => c.agent_stream_id === deskStream.public_id));
- assert.ok(!roomCalls.some((c) => c.agent_stream_id === privateStream.public_id));
+ assert.ok(roomCalls.some((c) => c.agent_stream_id === privateStream.public_id && c.visibility === 'private'), 'Chat hides an old room when its stream becomes private');
assert.strictEqual(roomCalls.find((c) => c.agent_stream_id === publicStream.public_id).owner_subject, SID.ann);
assert.strictEqual(roomCalls.find((c) => c.agent_stream_id === publicStream.public_id).owner_subject, SID.ann);
});
});