openvibers/openvibe.live Commits
Live: read a channel's chat insight from Chat's one room endpoint (#771)
Changes
@@@ -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,@@@ -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; },};@@@ -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');
@@ -153,10 +153,12 @@ router.get('/timeline/:username', async (req, res) => {
@@ -153,10 +153,12 @@ router.get('/timeline/:username', async (req, res) => {
})) : undefined;
})) : undefined;
// First page also carries the CHATTER side (how they behave in chat) + a combined
// 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) {
if (first) {
chatInsight = await insights.getUser(user.id);
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 { combinedOverview = await _combinedOverview(user.id, timeline.overview, chatInsight); } catch { combinedOverview = null; }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
}
}
@@ -166,6 +168,7 @@ router.get('/timeline/:username', async (req, res) => {
@@ -166,6 +168,7 @@ router.get('/timeline/:username', async (req, res) => {
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
overview: first ? timeline.overview : undefined,
overview: first ? timeline.overview : undefined,
chatInsight,
chatInsight,
+ channelInsight,
combinedOverview,
combinedOverview,
sessionCount: timeline.sessionCount,
sessionCount: timeline.sessionCount,
momentCount: timeline.momentCount,
momentCount: timeline.momentCount,
@@ -56,6 +56,35 @@ function read(key, path) {
@@ -56,6 +56,35 @@ function read(key, path) {
return p;
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) {
function peek(key, path) {
const hit = cached(key);
const hit = cached(key);
if (hit) return hit.value;
if (hit) return hit.value;
@@ -63,9 +92,17 @@ function peek(key, path) {
@@ -63,9 +92,17 @@ function peek(key, path) {
return null;
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 userPath = (id) => `/user/${Number(id)}`;
const anonPath = (id) => `/anon/${encodeURIComponent(String(id))}`;
const anonPath = (id) => `/anon/${encodeURIComponent(String(id))}`;
const relayPath = (platform, username) => `/relay/${encodeURIComponent(String(platform))}/${encodeURIComponent(String(username))}`;
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 validUser = (id) => Number.isInteger(Number(id)) && Number(id) > 0;
const validAnon = (id) => /^anon\d+$/i.test(String(id || ''));
const validAnon = (id) => /^anon\d+$/i.test(String(id || ''));
@@ -74,9 +111,12 @@ module.exports = {
@@ -74,9 +111,12 @@ module.exports = {
getUser: (id) => (validUser(id) ? read(`u:${Number(id)}`, userPath(id)) : Promise.resolve(null)),
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)),
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)),
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'),
peekGlobal: () => peek('g', '/global'),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(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),
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; },
_reset() { cache.clear(); inflight.clear(); lastLog = null; },
};
};
@@ -18,6 +18,7 @@ console.warn = () => {};
@@ -18,6 +18,7 @@ console.warn = () => {};
const GLOBAL = { overview: 'Chat is excited about the raid.', memory: 'Raid at 20:00.', timeline: [] };
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 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;
let failing = false;
const calls = [];
const calls = [];
const chat = http.createServer((req, res) => {
const chat = http.createServer((req, res) => {
@@ -29,6 +30,7 @@ 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/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/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/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('{}');
res.statusCode = 404; res.end('{}');
});
});
@@ -42,6 +44,7 @@ const chat = http.createServer((req, res) => {
@@ -42,6 +44,7 @@ const chat = http.createServer((req, res) => {
assert.deepStrictEqual(await client.getAnon('anon42'), { overview_24h: 'lurker' });
assert.deepStrictEqual(await client.getAnon('anon42'), { overview_24h: 'lurker' });
assert.deepStrictEqual(await client.getRelay('twitch', 'Some One'), { overview_24h: 'from twitch' });
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.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.
// Cached: repeated and concurrent reads cost one fetch.
const before = calls.length;
const before = calls.length;
@@ -59,11 +62,16 @@ const chat = http.createServer((req, res) => {
@@ -59,11 +62,16 @@ const chat = http.createServer((req, res) => {
assert.strictEqual(client.peekGlobal(), null);
assert.strictEqual(client.peekGlobal(), null);
await new Promise((r) => setTimeout(r, 100));
await new Promise((r) => setTimeout(r, 100));
assert.strictEqual(client.peekGlobal().overview, GLOBAL.overview);
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.
// Bad ids never reach Chat.
const n = calls.length;
const n = calls.length;
assert.strictEqual(await client.getUser('x'), null);
assert.strictEqual(await client.getUser('x'), null);
assert.strictEqual(await client.getAnon('../global'), null);
assert.strictEqual(await client.getAnon('../global'), null);
+ assert.strictEqual(await client.getChannel('x'), null);
assert.strictEqual(client.peekAnon('nope'), null);
assert.strictEqual(client.peekAnon('nope'), null);
assert.strictEqual(calls.length, n, 'no request for an invalid id');
assert.strictEqual(calls.length, n, 'no request for an invalid id');