openvibers/openvibe.live Commits
AI Moments thumbnails use the paste's real image URL (Community's create answer has none, so the job reads the paste back; Media's /p/<slug>/screenshot never knew Community pastes, so every moment since the move showed a broken image); deploy cards fold only when nobody has spoken in any room since, within 3 hours
Changes
@@@ -435,7 +435,8 @@ async function tick(opts = {}) {// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/// ai_summary/ai_tags/stream_id) are inherited-shape extensions the// contract leaves open — Media ignores what it doesn't know.-- const paste = await require('../pastes-client').createPaste({++ const pastesClient = require('../pastes-client');++ const paste = await pastesClient.createPaste({slug, user_id: v.user_id,title: title.slice(0, 80), content, language: 'text', visibility: 'public',stream_id: v.stream_id, metadata,@@@ -448,9 +449,15 @@ async function tick(opts = {}) {}, { origin: 'ai' });if (paste) {pasteSlug = paste.slug || slug;-- // Screenshot pastes must use the image endpoint — /raw serves the-- // (empty) text content and broke the hero background rotation.-- img = media.publicUrl(paste.screenshot_url) || `${media.pasteUrl(pasteSlug)}/screenshot`;++ // Screenshot pastes must use the image itself — /raw serves the (empty) text++ // content and broke the hero background rotation. Community's create answer has++ // no screenshot_url, so read the paste back for it; Media's /p/<slug>/screenshot++ // never knew Community pastes (every moment since the move had a broken image).++ let shot = paste.screenshot_url || null;++ if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }++ img = media.publicUrl(shot) || (pastesClient.onCommunity()++ ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`++ : `${media.pasteUrl(pasteSlug)}/screenshot`);}} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }@@@ -104,7 +104,7 @@ async function tick() {// screenshot_url is RELATIVE ("/p/<slug>/screenshot") and the file// lives on Media's disk, not ours — so it has to be fetched over// HTTP from Media's public base, not opened as a path.-- const img = p.slug ? media.pasteScreenshotUrl(p.slug) : null;++ const img = media.publicUrl(p.screenshot_url) || (p.slug ? media.pasteScreenshotUrl(p.slug) : null); // Community pastes carry their image URLif (img) r = await ai.analyzeImagePaste(img, p.title).catch(() => null);} else {r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);@@@ -9,8 +9,8 @@// Now:// • Only commits that were never announced are announced (site setting `deploy_last_announced`).// A restart with no new code says nothing.--// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy--// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.++// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a++// deploy notice (nobody has spoken anywhere since) and it is under 3 hours old, it is updated in place.// • 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@@@ -26,7 +26,8 @@ const path = require('path');const REPO_DIR = path.join(__dirname, '..', '..');const SETTING = 'deploy_last_announced';--const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;++// A card covers at most 3 hours of deploys, so its time range stays readable.++const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;const MAX_COMMITS = 40;const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart@@@ -63,7 +64,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */function persist(db, commits) {const nowIso = new Date().toISOString();-- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');++ // Newest across EVERY room: the global feed shows stream and channel messages too, so folding++ // while people chatted in a stream left a card whose time range ran past the messages under it.++ const newest = 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 */ }@@@ -6,7 +6,7 @@ const dn = require('../server/chat/deploy-notice');const rows = []; const settings = {};const db = {getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },-- get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,++ get: () => rows.filter(r => !r.is_deleted).slice(-1)[0] || undefined, // newest row of ANY roomrun: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },};@@@ -26,9 +26,16 @@ const third = dn.persist(db, [c(3)]);assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');assert.equal(third.meta.deploys, 1);--const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);--dn.persist(db, [c(4)]);--assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');++// A message in a STREAM room (not global, but shown in the global feed) also ends the card: folding++// past it would give the card a time range running past the message shown under it.++rows.push({ id: rows.length + 1, is_global: 0, is_deleted: 0, message_type: 'chat', message: 'hello brother', metadata: null });++const fourth = dn.persist(db, [c(4)]);++assert.equal(rows.length, 5, 'a stream-room message starts a new card too');++assert.equal(fourth.meta.deploys, 1);++++const old = JSON.parse(rows[4].metadata); old.first_at = new Date(Date.now() - 4 * 3600 * 1000).toISOString(); rows[4].metadata = JSON.stringify(old);++dn.persist(db, [c(5)]);++assert.equal(rows.length, 6, 'a notice older than the 3-hour fold window is not reopened');assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');console.log('deploy notice: all checks passed');
@@ -435,7 +435,8 @@ async function tick(opts = {}) {
@@ -435,7 +435,8 @@ async function tick(opts = {}) {
// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/
// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/
// ai_summary/ai_tags/stream_id) are inherited-shape extensions the
// ai_summary/ai_tags/stream_id) are inherited-shape extensions the
// contract leaves open — Media ignores what it doesn't know.
// contract leaves open — Media ignores what it doesn't know.
- const paste = await require('../pastes-client').createPaste({
+ const pastesClient = require('../pastes-client');
+ const paste = await pastesClient.createPaste({
slug, user_id: v.user_id,
slug, user_id: v.user_id,
title: title.slice(0, 80), content, language: 'text', visibility: 'public',
title: title.slice(0, 80), content, language: 'text', visibility: 'public',
stream_id: v.stream_id, metadata,
stream_id: v.stream_id, metadata,
@@ -448,9 +449,15 @@ async function tick(opts = {}) {
@@ -448,9 +449,15 @@ async function tick(opts = {}) {
}, { origin: 'ai' });
}, { origin: 'ai' });
if (paste) {
if (paste) {
pasteSlug = paste.slug || slug;
pasteSlug = paste.slug || slug;
- // Screenshot pastes must use the image endpoint — /raw serves the
- // (empty) text content and broke the hero background rotation.
- img = media.publicUrl(paste.screenshot_url) || `${media.pasteUrl(pasteSlug)}/screenshot`;
+ // Screenshot pastes must use the image itself — /raw serves the (empty) text
+ // content and broke the hero background rotation. Community's create answer has
+ // no screenshot_url, so read the paste back for it; Media's /p/<slug>/screenshot
+ // never knew Community pastes (every moment since the move had a broken image).
+ let shot = paste.screenshot_url || null;
+ if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }
+ img = media.publicUrl(shot) || (pastesClient.onCommunity()
+ ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`
+ : `${media.pasteUrl(pasteSlug)}/screenshot`);
}
}
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
@@ -104,7 +104,7 @@ async function tick() {
@@ -104,7 +104,7 @@ async function tick() {
// screenshot_url is RELATIVE ("/p/<slug>/screenshot") and the file
// screenshot_url is RELATIVE ("/p/<slug>/screenshot") and the file
// lives on Media's disk, not ours — so it has to be fetched over
// lives on Media's disk, not ours — so it has to be fetched over
// HTTP from Media's public base, not opened as a path.
// HTTP from Media's public base, not opened as a path.
- const img = p.slug ? media.pasteScreenshotUrl(p.slug) : null;
+ const img = media.publicUrl(p.screenshot_url) || (p.slug ? media.pasteScreenshotUrl(p.slug) : null); // Community pastes carry their image URL
if (img) r = await ai.analyzeImagePaste(img, p.title).catch(() => null);
if (img) r = await ai.analyzeImagePaste(img, p.title).catch(() => null);
} else {
} else {
r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);
r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
// Now:
// Now:
// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
// A restart with no new code says nothing.
// A restart with no new code says nothing.
-// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy
-// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.
+// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a
+// deploy notice (nobody has spoken anywhere since) and it is under 3 hours old, it is updated in place.
// • 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
@@ -26,7 +26,8 @@ const path = require('path');
@@ -26,7 +26,8 @@ const path = require('path');
const REPO_DIR = path.join(__dirname, '..', '..');
const REPO_DIR = path.join(__dirname, '..', '..');
const SETTING = 'deploy_last_announced';
const SETTING = 'deploy_last_announced';
-const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
+// A card covers at most 3 hours of deploys, so its time range stays readable.
+const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;
const MAX_COMMITS = 40;
const MAX_COMMITS = 40;
const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
@@ -63,7 +64,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le
@@ -63,7 +64,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le
/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
function persist(db, commits) {
function persist(db, commits) {
const nowIso = new Date().toISOString();
const nowIso = new Date().toISOString();
- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');
+ // Newest across EVERY room: the global feed shows stream and channel messages too, so folding
+ // while people chatted in a stream left a card whose time range ran past the messages under it.
+ const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
let prev = null;
let prev = null;
if (newest && newest.message_type === 'system' && newest.metadata) {
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 */ }
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 */ }
@@ -6,7 +6,7 @@ const dn = require('../server/chat/deploy-notice');
@@ -6,7 +6,7 @@ const dn = require('../server/chat/deploy-notice');
const rows = []; const settings = {};
const rows = []; const settings = {};
const db = {
const db = {
getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
- get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,
+ get: () => rows.filter(r => !r.is_deleted).slice(-1)[0] || undefined, // newest row of ANY room
run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
};
};
@@ -26,9 +26,16 @@ const third = dn.persist(db, [c(3)]);
@@ -26,9 +26,16 @@ const third = dn.persist(db, [c(3)]);
assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
assert.equal(third.meta.deploys, 1);
assert.equal(third.meta.deploys, 1);
-const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);
-dn.persist(db, [c(4)]);
-assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');
+// A message in a STREAM room (not global, but shown in the global feed) also ends the card: folding
+// past it would give the card a time range running past the message shown under it.
+rows.push({ id: rows.length + 1, is_global: 0, is_deleted: 0, message_type: 'chat', message: 'hello brother', metadata: null });
+const fourth = dn.persist(db, [c(4)]);
+assert.equal(rows.length, 5, 'a stream-room message starts a new card too');
+assert.equal(fourth.meta.deploys, 1);
+
+const old = JSON.parse(rows[4].metadata); old.first_at = new Date(Date.now() - 4 * 3600 * 1000).toISOString(); rows[4].metadata = JSON.stringify(old);
+dn.persist(db, [c(5)]);
+assert.equal(rows.length, 6, 'a notice older than the 3-hour fold window is not reopened');
assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
console.log('deploy notice: all checks passed');
console.log('deploy notice: all checks passed');