openvibers/openvibe.chat Commits

Timeline buckets say how many people wrote (item 151, C08) (#340)

OP OpenVibers committed 10 days ago · 3ebad2fad3 · 6 files, +16 −12

Live's VOD clip cutter can make an AI clip public only from a reacted room: 12+ messages from 3+ people. It read
the people as unknown, so every VOD clip stayed unlisted. Each bucket now carries people, the distinct chatters
(a user, an anonymous viewer or a relayed name, once each; a system line is nobody), as chat.timeline-result@1
allows from openvibe-contracts 0.283.0. The Contracts pin moves from v0.222.0 to v0.283.0 for it.

Changes

ddiff --git a/README.md b/README.md
iindex 560734c..c3aa7f0 100644
---- a/README.md
++++ b/README.md
@@@ -968,7 +968,7 @@ left OpenVibe.Sites, and this repository's vhost serves it.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.222.0
++- openvibe-contracts: v0.283.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.107.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 849c5f4..784795b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL)",
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
-- "contracts": "openvibe-contracts v0.222.0",
++ "contracts": "openvibe-contracts v0.283.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.107.0",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 6af9f9e..65e498e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.283.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.0",
"pg": "^8.23.0",
@@@ -1295,9 +1295,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.222.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
++ "version": "0.283.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.283.0",
++ "integrity": "sha512-tF09ZwL4wstIbAVeGqlkY35u37s/GqRCu7zJnGofKcC3NX2/RUL4BLP3IGMDafRMZ4LAQ4v0vg8euRcHy1DMIg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 3c8d2dc..f6c7059 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.283.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.0",
"pg": "^8.23.0",
ddiff --git a/server/chat/internal-reads.js b/server/chat/internal-reads.js
iindex 536fe92..6729bd9 100644
---- a/server/chat/internal-reads.js
++++ b/server/chat/internal-reads.js
@@@ -221,16 +221,18 @@ async function timeline(req) {
if (q.bucket_ms < 1000 || (until - q.since) / q.bucket_ms > MAX_BUCKETS) bad('Invalid bucket_ms');
const col = q.channel_user_id != null ? 'channel_user_id' : 'stream_id';
// Buckets start at since: bucket n covers [since + n·bucket_ms, since + (n+1)·bucket_ms).
++ // people: the different chatters in the bucket (CHATTER: a user, an anonymous viewer or a relayed name, once each).
++ // Live's VOD clip cutter reads 12+ messages from 3+ people as a reacted room (item 151, C08).
const rows = await db.all(
-- `SELECT n, COUNT(*) AS count, MAX(id) AS max_id FROM (
-- SELECT id, floor((extract(epoch FROM ov_ts(timestamp)) * 1000 - ?) / ?)::bigint AS n
++ `SELECT n, COUNT(*) AS count, COUNT(DISTINCT chatter) AS people, MAX(id) AS max_id FROM (
++ SELECT id, ${CHATTER} AS chatter, floor((extract(epoch FROM ov_ts(timestamp)) * 1000 - ?) / ?)::bigint AS n
FROM chat_messages
WHERE ${col} = ? AND is_deleted = 0 AND timestamp >= ? AND timestamp < ?
AND COALESCE(source_platform, '') <> 'ai') m
WHERE n IS NOT NULL AND n >= 0 GROUP BY n ORDER BY n`,
[q.since, q.bucket_ms, q[col], sqlTime(q.since), sqlTime(until)]);
return {
-- buckets: rows.map((r) => ({ t: q.since + Number(r.n) * q.bucket_ms, count: Number(r.count) })),
++ buckets: rows.map((r) => ({ t: q.since + Number(r.n) * q.bucket_ms, count: Number(r.count), ...(Number(r.people) > 0 ? { people: Number(r.people) } : {}) })),
max_id: rows.length ? Math.max(...rows.map((r) => Number(r.max_id))) : null,
};
}
ddiff --git a/test/chat-internal-reads.test.js b/test/chat-internal-reads.test.js
iindex 25a87af..eff5f35 100644
---- a/test/chat-internal-reads.test.js
++++ b/test/chat-internal-reads.test.js
@@@ -191,11 +191,13 @@ t('N2b dm block state: whether either user blocked the other', async () => {
t('N3 timeline: buckets from since, oldest first, empty ones left out', async () => {
const tl = (q, status) => read(`/timeline?${q}`, 'chat.analysis.read', 'chat.timeline-result@1', status);
++ // people: the different chatters (C08). The first minute is one viewer twice; the third is an anonymous viewer, a
++ // relayed Twitch name and the viewer again, and the system line is nobody.
assert.deepStrictEqual(await tl(`stream_id=${streamId}&since=${T}&until=${T + 600e3}&bucket_ms=60000`), {
-- ok: true, buckets: [{ t: T, count: 2 }, { t: T + 60e3, count: 2 }, { t: T + 120e3, count: 4 }], max_id: ids.sys,
++ ok: true, buckets: [{ t: T, count: 2, people: 1 }, { t: T + 60e3, count: 2, people: 2 }, { t: T + 120e3, count: 4, people: 3 }], max_id: ids.sys,
});
assert.deepStrictEqual(await tl(`channel_user_id=${streamer.id}&since=${T + 30e3}&until=${T + 90e3}&bucket_ms=30000`), {
-- ok: true, buckets: [{ t: T + 30e3, count: 1 }, { t: T + 60e3, count: 2 }], max_id: ids.v3,
++ ok: true, buckets: [{ t: T + 30e3, count: 1, people: 1 }, { t: T + 60e3, count: 2, people: 2 }], max_id: ids.v3,
});
assert.deepStrictEqual(await tl(`stream_id=${streamId}&since=${T + 900e3}&until=${T + 960e3}&bucket_ms=60000`), { ok: true, buckets: [], max_id: null });
for (const q of [`since=${T}&bucket_ms=60000`, `stream_id=${streamId}&channel_user_id=${streamer.id}&since=${T}&bucket_ms=60000`,