openvibers/openvibe.live Commits

Live: read a channel's chat insight from Chat's one room endpoint (#771)

OP OpenVibers committed 4 days ago · f75eae004c · 3 files, +53 −2

Changes

ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex b2a99911..387418ff 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -153,10 +153,12 @@ router.get('/timeline/:username', async (req, res) => {
})) : undefined;
// First page also carries the CHATTER side (how they behave in chat) + a combined
-- // "whole person" overview fusing streamer + chatter. Both are best-effort.
-- let chatInsight, combinedOverview;
++ // "whole person" overview fusing streamer + chatter. Both are best-effort. The channel's chat
++ // insight is the same one shape every Chat room answers (Chat's /api/v1/rooms/channel:<id>/insight).
++ let chatInsight, combinedOverview, channelInsight;
if (first) {
chatInsight = await insights.getUser(user.id);
++ channelInsight = await insights.getChannel(user.id);
try { combinedOverview = await _combinedOverview(user.id, timeline.overview, chatInsight); } catch { combinedOverview = null; }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
}
@@@ -166,6 +168,7 @@ router.get('/timeline/:username', async (req, res) => {
display_name: user.display_name || user.username,
overview: first ? timeline.overview : undefined,
chatInsight,
++ channelInsight,
combinedOverview,
sessionCount: timeline.sessionCount,
momentCount: timeline.momentCount,
ddiff --git a/server/chat/insight-client.js b/server/chat/insight-client.js
iindex 70395afe..f54286ac 100644
---- a/server/chat/insight-client.js
++++ b/server/chat/insight-client.js
@@@ -56,6 +56,35 @@ function read(key, path) {
return p;
}
++/**
++ * A room's insight (plan 2026-11-10): the shape Chat's one endpoint answers for every room, here for a
++ * Live channel's chat keyed by the broadcaster's user id. It is the same answer a Chat room gets:
++ * { overview, timeline, state }. Cached and fail-soft like the other reads.
++ */
++function readRoom(key, path) {
++ const hit = cached(key);
++ if (hit) return Promise.resolve(hit.value);
++ if (inflight.has(key)) return inflight.get(key);
++ const p = (async () => {
++ if (drill()) return null;
++ try {
++ const res = await fetch(`${CHAT_URL}${path}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (!res.ok) throw new Error(`answered ${res.status}`);
++ const value = await res.json().catch(() => null);
++ lastLog = null;
++ remember(key, value || null);
++ return value || null;
++ } catch (err) {
++ const m = `OpenVibe.Chat room insight ${path}: ${(err && err.message) || err}`;
++ if (m !== lastLog) { lastLog = m; console.warn(`[ChatInsight] ${m}`); }
++ remember(key, null);
++ return null;
++ }
++ })().finally(() => inflight.delete(key));
++ inflight.set(key, p);
++ return p;
++}
++
function peek(key, path) {
const hit = cached(key);
if (hit) return hit.value;
@@@ -63,9 +92,17 @@ function peek(key, path) {
return null;
}
++function peekRoom(key, path) {
++ const hit = cached(key);
++ if (hit) return hit.value;
++ readRoom(key, path).catch(() => { /* logged in readRoom() */ }); // floating-ok: warms the cache
++ return null;
++}
++
const userPath = (id) => `/user/${Number(id)}`;
const anonPath = (id) => `/anon/${encodeURIComponent(String(id))}`;
const relayPath = (platform, username) => `/relay/${encodeURIComponent(String(platform))}/${encodeURIComponent(String(username))}`;
++const channelPath = (id) => `/api/v1/rooms/channel:${Number(id)}/insight`;
const validUser = (id) => Number.isInteger(Number(id)) && Number(id) > 0;
const validAnon = (id) => /^anon\d+$/i.test(String(id || ''));
@@@ -74,9 +111,12 @@ module.exports = {
getUser: (id) => (validUser(id) ? read(`u:${Number(id)}`, userPath(id)) : Promise.resolve(null)),
getAnon: (id) => (validAnon(id) ? read(`a:${id}`, anonPath(id)) : Promise.resolve(null)),
getRelay: (platform, username) => (platform && username ? read(`r:${platform}:${username}`, relayPath(platform, username)) : Promise.resolve(null)),
++ /** A Live channel's chat insight, the same shape a Chat room answers (channel:<broadcaster user id>). */
++ getChannel: (id) => (validUser(id) ? readRoom(`c:${Number(id)}`, channelPath(id)) : Promise.resolve(null)),
peekGlobal: () => peek('g', '/global'),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(id)) : null),
peekRelay: (platform, username) => (platform && username ? peek(`r:${platform}:${username}`, relayPath(platform, username)) : null),
++ peekChannel: (id) => (validUser(id) ? peekRoom(`c:${Number(id)}`, channelPath(id)) : null),
_reset() { cache.clear(); inflight.clear(); lastLog = null; },
};
ddiff --git a/test/chat-insight-client.test.js b/test/chat-insight-client.test.js
iindex d0fb1b7c..ea067f25 100644
---- a/test/chat-insight-client.test.js
++++ b/test/chat-insight-client.test.js
@@@ -18,6 +18,7 @@ console.warn = () => {};
const GLOBAL = { overview: 'Chat is excited about the raid.', memory: 'Raid at 20:00.', timeline: [] };
const USER = { overview_24h: 'Asks about builds.', memory: 'Plays support.' };
++const CHANNEL = { overview: { text: 'The channel is planning a raid.', topics: [{ label: 'raid', count: 3 }], people: 4, messages: 9, from: '2026-11-10 08:00:00', to: '2026-11-10 09:00:00', updated_at: '2026-11-10 09:00:02' }, timeline: [], state: 'ready' };
let failing = false;
const calls = [];
const chat = http.createServer((req, res) => {
@@@ -29,6 +30,7 @@ const chat = http.createServer((req, res) => {
if (req.url === '/api/chat/ai/anon/anon42') return res.end(JSON.stringify({ insight: { overview_24h: 'lurker' } }));
if (req.url === '/api/chat/ai/relay/twitch/Some%20One') return res.end(JSON.stringify({ insight: { overview_24h: 'from twitch' } }));
if (req.url === '/api/chat/ai/user/8') return res.end(JSON.stringify({ insight: null }));
++ if (req.url === '/api/v1/rooms/channel:7/insight') return res.end(JSON.stringify(CHANNEL));
res.statusCode = 404; res.end('{}');
});
@@@ -42,6 +44,7 @@ const chat = http.createServer((req, res) => {
assert.deepStrictEqual(await client.getAnon('anon42'), { overview_24h: 'lurker' });
assert.deepStrictEqual(await client.getRelay('twitch', 'Some One'), { overview_24h: 'from twitch' });
assert.strictEqual(await client.getUser(8), null, 'no insight yet answers null');
++ assert.deepStrictEqual(await client.getChannel(7), CHANNEL, 'a channel answers the room shape');
// Cached: repeated and concurrent reads cost one fetch.
const before = calls.length;
@@@ -59,11 +62,16 @@ const chat = http.createServer((req, res) => {
assert.strictEqual(client.peekGlobal(), null);
await new Promise((r) => setTimeout(r, 100));
assert.strictEqual(client.peekGlobal().overview, GLOBAL.overview);
++ client._reset();
++ assert.strictEqual(client.peekChannel(7), null, 'a cold channel peek answers null');
++ await new Promise((r) => setTimeout(r, 100));
++ assert.deepStrictEqual(client.peekChannel(7), CHANNEL, 'the channel peek warmed the cache');
// Bad ids never reach Chat.
const n = calls.length;
assert.strictEqual(await client.getUser('x'), null);
assert.strictEqual(await client.getAnon('../global'), null);
++ assert.strictEqual(await client.getChannel('x'), null);
assert.strictEqual(client.peekAnon('nope'), null);
assert.strictEqual(calls.length, n, 'no request for an invalid id');