openvibers/openvibe.chat Commits
Timeline buckets say how many people wrote (item 151, C08) (#340)
Changes
@@@ -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 -->@@@ -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": [@@@ -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",@@@ -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",@@@ -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 nFROM chat_messagesWHERE ${col} = ? AND is_deleted = 0 AND timestamp >= ? AND timestamp < ?AND COALESCE(source_platform, '') <> 'ai') mWHERE 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,};}@@@ -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`,
@@ -968,7 +968,7 @@ left OpenVibe.Sites, and this repository's vhost serves it.
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.222.0
+- openvibe-contracts: v0.283.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.107.0
- openvibe-shared: v4.107.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -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)",
"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,
"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)",
"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",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.107.0",
"shared": "openvibe-shared v4.107.0",
"capabilities": [
"capabilities": [
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1295,9 +1295,9 @@
@@ -1295,9 +1295,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.107.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -221,16 +221,18 @@ async function timeline(req) {
@@ -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');
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';
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).
// 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(
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
FROM chat_messages
WHERE ${col} = ? AND is_deleted = 0 AND timestamp >= ? AND timestamp < ?
WHERE ${col} = ? AND is_deleted = 0 AND timestamp >= ? AND timestamp < ?
AND COALESCE(source_platform, '') <> 'ai') m
AND COALESCE(source_platform, '') <> 'ai') m
WHERE n IS NOT NULL AND n >= 0 GROUP BY n ORDER BY n`,
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)]);
[q.since, q.bucket_ms, q[col], sqlTime(q.since), sqlTime(until)]);
return {
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,
max_id: rows.length ? Math.max(...rows.map((r) => Number(r.max_id))) : null,
};
};
}
}
@@ -191,11 +191,13 @@ t('N2b dm block state: whether either user blocked the other', async () => {
@@ -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 () => {
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);
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`), {
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`), {
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 });
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`,
for (const q of [`since=${T}&bucket_ms=60000`, `stream_id=${streamId}&channel_user_id=${streamer.id}&since=${T}&bucket_ms=60000`,