openvibers/openvibe.chat Commits
Fold deploy notices across system announcements (#306)
Changes
@@@ -13,9 +13,10 @@// keep what it recorded.//// Chat keeps the part that is chat:--// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a--// deploy notice (nobody has spoken since) and its first deploy is under 3 hours old, that row is--// updated in place.++// • Consecutive deploys fold into ONE stored message: while no person has spoken in ANY room since++// the card and its first deploy is under 3 hours old, that row is updated in place. System rows++// (go-live announcements, rundown lines, goal notices) may land between the deploys and do not++// break the fold; only a line an actual person wrote does. The card stays at its row id.// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,// updated_at }. Clients render times from ISO timestamps, so they are always right.// • The live broadcast carries the row id; clients replace the card with that id instead of@@@ -49,24 +50,47 @@ const cleanCommits = (commits) => (Array.isArray(commits) ? commits : []).filterconst plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;/**-- * Insert a notice, or fold into the newest row when that row is itself a recent deploy notice.-- * "Newest" is across EVERY room: the global feed shows stream and channel messages too, so a card-- * that kept folding while people chatted in a stream ended up with a time range running past the-- * messages under it. Anyone speaking anywhere since the last card starts a new card.++ * The recent deploy card to fold into, or null when a person has spoken since it. The card is the++ * newest row with metadata kind 'deploy' above the newest row a person wrote (user_id, anon_id or++ * a relayed source_platform). System notices — go-live announcements, the rundown, goal lines —++ * carry no author and the deploy cards themselves are system rows, so they do not hide it.++ * Live's AI bots post with source_platform 'ai' and are not people either.++ */++async function foldTarget(db, nowMs) {++ const person = await db.get(`SELECT id FROM chat_messages++ WHERE is_deleted = 0 AND message_type <> 'system'++ AND (user_id IS NOT NULL OR anon_id IS NOT NULL++ OR (message_type = 'chat' AND COALESCE(source_platform, '') <> 'ai')++ OR (source_platform IS NOT NULL AND source_platform <> 'ai'))++ ORDER BY id DESC LIMIT 1`);++ const floor = person ? Number(person.id) : 0;++ const row = await db.get(`SELECT id, metadata FROM chat_messages++ WHERE is_deleted = 0 AND message_type = 'system' AND id > ? AND metadata LIKE ?++ ORDER BY id DESC LIMIT 1`, [floor, '%"kind":"deploy"%']);++ if (!row || !row.metadata) return null;++ try {++ const meta = JSON.parse(row.metadata);++ if (meta && meta.kind === 'deploy' && nowMs - Date.parse(meta.first_at) < FOLD_WINDOW_MS) return { id: row.id, meta };++ } catch { /* not ours */ }++ return null;++}++++/**++ * Insert a notice, or fold into the newest recent deploy card when no person has spoken since it.++ * The card is chosen across EVERY room: the global feed shows stream and channel messages too, so a++ * card that kept folding while people chatted in a stream ended up with a time range running past++ * the messages under it. A person speaking anywhere since the card starts a new one; system notices++ * do not.*/async function persist(db, commits) {const nowIso = new Date().toISOString();-- const newest = await db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');-- let prev = null;-- if (newest && newest.message_type === 'system' && newest.metadata) {-- try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }-- }-- if (prev) {++ const target = await foldTarget(db, Date.now());++ if (target) {const seen = new Set(commits.map(c => c.hash));-- const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);-- const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };-- await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);-- return { id: newest.id, meta, folded: true };++ const merged = commits.concat((target.meta.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);++ const meta = { kind: 'deploy', commits: merged, deploys: (target.meta.deploys || 1) + 1, first_at: target.meta.first_at, updated_at: nowIso };++ await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), target.id]);++ return { id: target.id, meta, folded: true };}const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };const res = await db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });@@@ -105,6 +105,12 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, stripconst cards = async () => (await h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id")).map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));const card = async (id) => (await cards()).find((c) => c.id === id);const speak = async (text) => await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });++// Live's go-live line (server/streaming/live-events.js postLiveLink): a system row with no person behind it.++const goLive = async () => await h.db.saveChatMessage({++ stream_id: streamId, channel_user_id: streamer.id, user_id: null, username: 'OpenVibe.Live',++ message: '🔴 Streamer is live. Share the stream: https://openvibe.live/@streamer',++ message_type: 'system', is_global: false, metadata: { kind: 'live_link', url: 'https://openvibe.live/@streamer' },++});const releaseRow = async (head) => await h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);t('boot with the consumer on and a stub Events', async () => {@@@ -251,6 +257,32 @@ t('folding as before: the next deploy folds into the last card; a stream messageassert.strictEqual((await card(first)).deploys, 3, 'the old card is left as it was');});++t('folding crosses system notices: a go-live between deploys folds; a person or 3 hours starts a new card', async () => {++ await speak('a person after the last card');++ let r = await deliver(release([commit('e1', 'notice: one')]));++ assert.strictEqual(r.body.outcome, 'announced');++ const first = r.body.detail.message_id;++ // Live announces the stream in the same channel: a system row, not a person, above the card.++ await goLive();++ r = await deliver(release([commit('e2', 'notice: two')]));++ assert.deepStrictEqual([r.body.outcome, r.body.detail.message_id], ['folded', first]);++ const folded = await card(first);++ assert.strictEqual(folded.deploys, 2);++ assert.deepStrictEqual(folded.commits.map((c) => c.subject), ['notice: two', 'notice: one']);++ // A person's line above the card still starts a new one.++ await speak('someone says hi');++ r = await deliver(release([commit('e3', 'notice: three')]));++ assert.strictEqual(r.body.outcome, 'announced');++ const second = r.body.detail.message_id;++ assert.notStrictEqual(second, first);++ // The 3-hour window still starts a new card (the second card's first deploy is now old).++ const old = JSON.parse((await h.db.get('SELECT metadata FROM chat_messages WHERE id = ?', [second])).metadata);++ await h.db.run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...old, first_at: new Date(Date.now() - 4 * 3600e3).toISOString() }), second]);++ r = await deliver(release([commit('e4', 'notice: four')]));++ assert.strictEqual(r.body.outcome, 'announced');++ assert.notStrictEqual(r.body.detail.message_id, second);++});++t('what a release event must be: from Live, a release subject, commits, not stale', async () => {const before = (await cards()).length;const ev = release([commit('5')]);
@@ -13,9 +13,10 @@
@@ -13,9 +13,10 @@
// keep what it recorded.
// keep what it recorded.
//
//
// Chat keeps the part that is chat:
// Chat keeps the part that is chat:
-// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a
-// deploy notice (nobody has spoken since) and its first deploy is under 3 hours old, that row is
-// updated in place.
+// • Consecutive deploys fold into ONE stored message: while no person has spoken in ANY room since
+// the card and its first deploy is under 3 hours old, that row is updated in place. System rows
+// (go-live announcements, rundown lines, goal notices) may land between the deploys and do not
+// break the fold; only a line an actual person wrote does. The card stays at its row id.
// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
// updated_at }. Clients render times from ISO timestamps, so they are always right.
// updated_at }. Clients render times from ISO timestamps, so they are always right.
// • The live broadcast carries the row id; clients replace the card with that id instead of
// • The live broadcast carries the row id; clients replace the card with that id instead of
@@ -49,24 +50,47 @@ const cleanCommits = (commits) => (Array.isArray(commits) ? commits : []).filter
@@ -49,24 +50,47 @@ const cleanCommits = (commits) => (Array.isArray(commits) ? commits : []).filter
const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
/**
/**
- * Insert a notice, or fold into the newest row when that row is itself a recent deploy notice.
- * "Newest" is across EVERY room: the global feed shows stream and channel messages too, so a card
- * that kept folding while people chatted in a stream ended up with a time range running past the
- * messages under it. Anyone speaking anywhere since the last card starts a new card.
+ * The recent deploy card to fold into, or null when a person has spoken since it. The card is the
+ * newest row with metadata kind 'deploy' above the newest row a person wrote (user_id, anon_id or
+ * a relayed source_platform). System notices — go-live announcements, the rundown, goal lines —
+ * carry no author and the deploy cards themselves are system rows, so they do not hide it.
+ * Live's AI bots post with source_platform 'ai' and are not people either.
+ */
+async function foldTarget(db, nowMs) {
+ const person = await db.get(`SELECT id FROM chat_messages
+ WHERE is_deleted = 0 AND message_type <> 'system'
+ AND (user_id IS NOT NULL OR anon_id IS NOT NULL
+ OR (message_type = 'chat' AND COALESCE(source_platform, '') <> 'ai')
+ OR (source_platform IS NOT NULL AND source_platform <> 'ai'))
+ ORDER BY id DESC LIMIT 1`);
+ const floor = person ? Number(person.id) : 0;
+ const row = await db.get(`SELECT id, metadata FROM chat_messages
+ WHERE is_deleted = 0 AND message_type = 'system' AND id > ? AND metadata LIKE ?
+ ORDER BY id DESC LIMIT 1`, [floor, '%"kind":"deploy"%']);
+ if (!row || !row.metadata) return null;
+ try {
+ const meta = JSON.parse(row.metadata);
+ if (meta && meta.kind === 'deploy' && nowMs - Date.parse(meta.first_at) < FOLD_WINDOW_MS) return { id: row.id, meta };
+ } catch { /* not ours */ }
+ return null;
+}
+
+/**
+ * Insert a notice, or fold into the newest recent deploy card when no person has spoken since it.
+ * The card is chosen across EVERY room: the global feed shows stream and channel messages too, so a
+ * card that kept folding while people chatted in a stream ended up with a time range running past
+ * the messages under it. A person speaking anywhere since the card starts a new one; system notices
+ * do not.
*/
*/
async function persist(db, commits) {
async function persist(db, commits) {
const nowIso = new Date().toISOString();
const nowIso = new Date().toISOString();
- const newest = await db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
- let prev = null;
- if (newest && newest.message_type === 'system' && newest.metadata) {
- try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
- }
- if (prev) {
+ const target = await foldTarget(db, Date.now());
+ if (target) {
const seen = new Set(commits.map(c => c.hash));
const seen = new Set(commits.map(c => c.hash));
- const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
- const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
- await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
- return { id: newest.id, meta, folded: true };
+ const merged = commits.concat((target.meta.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
+ const meta = { kind: 'deploy', commits: merged, deploys: (target.meta.deploys || 1) + 1, first_at: target.meta.first_at, updated_at: nowIso };
+ await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), target.id]);
+ return { id: target.id, meta, folded: true };
}
}
const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
const res = await db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
const res = await db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
@@ -105,6 +105,12 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, strip
@@ -105,6 +105,12 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, strip
const cards = async () => (await h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id")).map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));
const cards = async () => (await h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id")).map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));
const card = async (id) => (await cards()).find((c) => c.id === id);
const card = async (id) => (await cards()).find((c) => c.id === id);
const speak = async (text) => await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });
const speak = async (text) => await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });
+// Live's go-live line (server/streaming/live-events.js postLiveLink): a system row with no person behind it.
+const goLive = async () => await h.db.saveChatMessage({
+ stream_id: streamId, channel_user_id: streamer.id, user_id: null, username: 'OpenVibe.Live',
+ message: '🔴 Streamer is live. Share the stream: https://openvibe.live/@streamer',
+ message_type: 'system', is_global: false, metadata: { kind: 'live_link', url: 'https://openvibe.live/@streamer' },
+});
const releaseRow = async (head) => await h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);
const releaseRow = async (head) => await h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);
t('boot with the consumer on and a stub Events', async () => {
t('boot with the consumer on and a stub Events', async () => {
@@ -251,6 +257,32 @@ t('folding as before: the next deploy folds into the last card; a stream message
@@ -251,6 +257,32 @@ t('folding as before: the next deploy folds into the last card; a stream message
assert.strictEqual((await card(first)).deploys, 3, 'the old card is left as it was');
assert.strictEqual((await card(first)).deploys, 3, 'the old card is left as it was');
});
});
+t('folding crosses system notices: a go-live between deploys folds; a person or 3 hours starts a new card', async () => {
+ await speak('a person after the last card');
+ let r = await deliver(release([commit('e1', 'notice: one')]));
+ assert.strictEqual(r.body.outcome, 'announced');
+ const first = r.body.detail.message_id;
+ // Live announces the stream in the same channel: a system row, not a person, above the card.
+ await goLive();
+ r = await deliver(release([commit('e2', 'notice: two')]));
+ assert.deepStrictEqual([r.body.outcome, r.body.detail.message_id], ['folded', first]);
+ const folded = await card(first);
+ assert.strictEqual(folded.deploys, 2);
+ assert.deepStrictEqual(folded.commits.map((c) => c.subject), ['notice: two', 'notice: one']);
+ // A person's line above the card still starts a new one.
+ await speak('someone says hi');
+ r = await deliver(release([commit('e3', 'notice: three')]));
+ assert.strictEqual(r.body.outcome, 'announced');
+ const second = r.body.detail.message_id;
+ assert.notStrictEqual(second, first);
+ // The 3-hour window still starts a new card (the second card's first deploy is now old).
+ const old = JSON.parse((await h.db.get('SELECT metadata FROM chat_messages WHERE id = ?', [second])).metadata);
+ await h.db.run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...old, first_at: new Date(Date.now() - 4 * 3600e3).toISOString() }), second]);
+ r = await deliver(release([commit('e4', 'notice: four')]));
+ assert.strictEqual(r.body.outcome, 'announced');
+ assert.notStrictEqual(r.body.detail.message_id, second);
+});
+
t('what a release event must be: from Live, a release subject, commits, not stale', async () => {
t('what a release event must be: from Live, a release subject, commits, not stale', async () => {
const before = (await cards()).length;
const before = (await cards()).length;
const ev = release([commit('5')]);
const ev = release([commit('5')]);