openvibers/openvibe.live Commits

Show agent chat and supply recorded steps to Video (#809)

OP OpenVibers committed 2 days ago · e6f3b74c33 · 8 files, +111 −5

* Show agent stream chat and replay steps on VODs

* Recheck agent chat links against current stream visibility

* Hide archived agent chat after the stream ends

* Expect private visibility sync in Co-pilot room tests

* Merge main into agent stream S2 follow-up

Changes

ddiff --git a/public/css/features/agent-stream.css b/public/css/features/agent-stream.css
iindex 732e6cb0..b02b6c82 100644
---- a/public/css/features/agent-stream.css
++++ b/public/css/features/agent-stream.css
@@@ -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; }
ddiff --git a/public/fragments/agent-stream.html b/public/fragments/agent-stream.html
iindex e7de00a4..7046ed50 100644
---- a/public/fragments/agent-stream.html
++++ b/public/fragments/agent-stream.html
@@@ -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">
ddiff --git a/public/js/agent-stream.js b/public/js/agent-stream.js
iindex 52086d00..43b14d73 100644
---- a/public/js/agent-stream.js
++++ b/public/js/agent-stream.js
@@@ -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(() => {});
ddiff --git a/server/agent-streams/routes.js b/server/agent-streams/routes.js
iindex bbc01777..305f49ed 100644
---- a/server/agent-streams/routes.js
++++ b/server/agent-streams/routes.js
@@@ -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);
ddiff --git a/server/agent-streams/service.js b/server/agent-streams/service.js
iindex 7a81ee5c..61945584 100644
---- a/server/agent-streams/service.js
++++ b/server/agent-streams/service.js
@@@ -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;
}
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 9b38b507..e6e03602 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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
ddiff --git a/test/agent-streams-s2.test.js b/test/agent-streams-s2.test.js
iindex aaac9a2a..b9fd750a 100644
---- a/test/agent-streams-s2.test.js
++++ b/test/agent-streams-s2.test.js
@@@ -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;
ddiff --git a/test/agent-streams-s5.test.js b/test/agent-streams-s5.test.js
iindex 45b78115..9c2f3457 100644
---- a/test/agent-streams-s5.test.js
++++ b/test/agent-streams-s5.test.js
@@@ -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);
});