openvibers/openvibe.live Commits
Live: every channel links the streamer's OpenVibe profile (openvibe.network/@username), with a card showing their picture, item count and what they wear; the streamer can hide it (#57)
Changes
@@@ -13,7 +13,7 @@for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;return 'just now';};-- const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));++ const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon', 'openvibe'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));/** Pills: one line of links (the offline screen). */function pills(host, links) {@@@ -105,7 +105,7 @@<label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `<label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>-- <i class="${escH(c.icon)}"></i> Show your connected ${escH(c.name)} (${escH(label(c))})</label>`).join('')}</div>` : ''}++ <i class="${escH(c.icon)}"></i> ${c.kind === 'openvibe' ? 'Show your OpenVibe profile' : `Show your connected ${escH(c.name)}`} (${escH(label(c))})</label>`).join('')}</div>` : ''}${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}<div class="ch-social-rows">${s.links.map((l, i) => `<div class="ch-social-row" data-i="${i}">@@@ -6,7 +6,8 @@** cleanSocialLinks(input) → the list to store, or null when it is not a valid list* channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already-- * listed and not hidden (channels.social_links.hidden_auto)++ * listed and not hidden (channels.social_links.hidden_auto), then the++ * streamer's OpenVibe profile (openvibe.network/@username), also hideable*/const MAX_LINKS = 16;@@@ -32,10 +33,12 @@ const PLATFORMS = {soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },++ openvibe: { name: 'OpenVibe', icon: 'fa-solid fa-id-card', color: '#3b82f6', hosts: ['openvibe.network'], url: (h) => `https://openvibe.network/@${h}`, handle: /^\/@([A-Za-z0-9_]{3,24})\/?$/ },website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },};--const AUTO_KINDS = ['twitch', 'youtube', 'kick'];++// Shown without being added: the restream platforms a channel is connected to, and its OpenVibe profile.++const AUTO_KINDS = ['twitch', 'youtube', 'kick', 'openvibe'];const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));@@@ -127,6 +130,15 @@ async function channelSocialLinks(channel, db, { owner = false } = {}) {auto.push(d);if (!d.hidden) { out.push(d); kinds.add(c.platform); }}++ // The streamer's OpenVibe profile (plan T21): their picture, bio and the items they wear, one card on every channel.++ if (channel && channel.username && !kinds.has('openvibe')) {++ const l = cleanLink({ kind: 'openvibe', handle: channel.username });++ if (l) {++ const d = decorate(l, { auto: true, hidden: stored.hidden_auto.includes('openvibe') });++ auto.push(d);++ if (!d.hidden) { out.push(d); kinds.add('openvibe'); }++ }++ }const result = { links: out };if (owner) {let dests = [];@@@ -9,7 +9,8 @@** Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub* (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the-- * channel is offline), Kick (its public channel API, best effort), and OpenGraph for everything else. X has no++ * channel is offline), Kick (its public channel API, best effort), the streamer's OpenVibe profile (the Network's++ * /api/v1/profiles, on loopback), and OpenGraph for everything else. X has no* usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated* frame, /embed/x-timeline).*/@@@ -34,6 +35,7 @@ async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++const RARITY = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);/** OpenGraph / meta tags of a page. */@@@ -124,6 +126,27 @@ const ADAPTERS = {items: [],};},++ async openvibe(l) {++ // The streamer's OpenVibe profile, from the Network's public profile API (ours, on loopback; the handle is a++ // checked username): their picture, how many items they own and what they wear.++ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ const r = await fetch(`${base}/api/v1/profiles/${encodeURIComponent(l.handle)}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });++ if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });++ const p = (await r.json()).profile || {};++ const title = clip(p.display_name || `@${l.handle}`, 80);++ if (p.private) return { title, subtitle: 'Profile on OpenVibe', image: httpsOnly(p.avatar_url), items: [] };++ const n = p.items && !p.items.unavailable ? Number(p.items.count) || 0 : null;++ const since = /^\d{4}/.test(String(p.member_since || '')) ? `on OpenVibe since ${String(p.member_since).slice(0, 4)}` : null;++ return {++ title,++ subtitle: [n === null ? null : `${n.toLocaleString('en-US')}${p.items.more ? '+' : ''} item${n === 1 ? '' : 's'}`, since].filter(Boolean).join(' · ') || 'Profile on OpenVibe',++ image: httpsOnly(p.avatar_url),++ items: (Array.isArray(p.showcase) ? p.showcase : []).slice(0, 3).map((s) => ({++ text: clip(`${s.art && s.art.emoji ? `${String(s.art.emoji).slice(0, 8)} ` : ''}Wearing ${s.name} (${RARITY[s.rarity] || 'Common'} ${String(s.kind_name || '').toLowerCase()})`, 120),++ url: httpsOnly(s.url),++ })),++ };++ },async x(l) {// No free API: the card links out; the page offers X's own embed on click.return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };@@@ -138,7 +161,7 @@ async function preview(l) {let value;try {const fn = ADAPTERS[l.kind] || (async (x) => await openGraph(x.url));-- const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x'].includes(l.kind);++ const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x', 'openvibe'].includes(l.kind);value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);value = { kind: l.kind, url: l.url, items: [], ...value };remember(key, value, TTL);@@@ -345,6 +345,7 @@ async function check(name, fn) {'server/pastes-client.js': 'OpenVibe.Community (configured)','server/comments-client.js': 'OpenVibe.Community (configured)','server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',++ 'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress','server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)','server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)','server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',@@@ -46,6 +46,39 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${nameassert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);});++ await check('the OpenVibe profile: every channel shows openvibe.network/@username after its other links, unless listed or hidden', async () => {++ const db = { all: () => [] };++ const shown = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'x', handle: 'goosely' }] }) }, db, { owner: true });++ assert.deepStrictEqual(shown.links.map((l) => [l.kind, l.url, !!l.auto]), [['x', 'https://x.com/goosely', false], ['openvibe', 'https://openvibe.network/@Goosely', true]]);++ assert.deepStrictEqual(shown.connected.map((c) => [c.kind, c.hidden]), [['openvibe', false]], 'the editor can hide it');++ const hidden = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [], hidden_auto: ['openvibe'] }) }, db);++ assert.deepStrictEqual(hidden.links, []);++ const listed = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'custom', url: 'https://openvibe.network/@Goosely' }] }) }, db);++ assert.deepStrictEqual(listed.links.map((l) => [l.kind, !!l.auto]), [['openvibe', false]], 'listed by hand: once');++ assert.strictEqual(cleanLink({ kind: 'openvibe', url: 'https://evil.example/@x' }), null);++ });++++ await check('the OpenVibe profile preview: the Network\'s profile API, the picture, item count and what they wear; a private one says so', async () => {++ const { preview: pv } = require('../server/social/preview');++ const realFetch = global.fetch;++ const seen = [];++ global.fetch = async (url) => {++ seen.push(String(url));++ const name = decodeURIComponent(String(url).split('/').pop());++ if (name === 'Goosely') return { ok: true, json: async () => ({ profile: { username: 'Goosely', display_name: 'Goosely', avatar_url: 'https://openvibe.network/avatar/Goosely?s=160', private: false, member_since: '2025-03-14', items: { count: 12, more: false }, showcase: [{ name: 'Void Crown', rarity: 'legendary', kind_name: 'Hat', art: { emoji: '🕳️' }, url: 'https://inventory.openvibe.network/items/itd_1' }] } }) };++ if (name === 'quiet') return { ok: true, json: async () => ({ profile: { username: 'quiet', display_name: 'Quiet', avatar_url: 'https://openvibe.network/avatar/quiet?s=160', private: true } }) };++ return { ok: false, status: 404, json: async () => ({}) };++ };++ try {++ const p = await pv(cleanLink({ kind: 'openvibe', handle: 'Goosely' }));++ assert.ok(seen[0].endsWith('/api/v1/profiles/Goosely') && seen[0].startsWith('http://127.0.0.1:4000'), 'loopback Network, fixed path');++ assert.deepStrictEqual([p.title, p.subtitle, p.image], ['Goosely', '12 items · on OpenVibe since 2025', 'https://openvibe.network/avatar/Goosely?s=160']);++ assert.deepStrictEqual(p.items, [{ text: '🕳️ Wearing Void Crown (Legendary hat)', url: 'https://inventory.openvibe.network/items/itd_1' }]);++ const q = await pv(cleanLink({ kind: 'openvibe', handle: 'quiet' }));++ assert.deepStrictEqual([q.title, q.subtitle, q.items], ['Quiet', 'Profile on OpenVibe', []]);++ } finally { global.fetch = realFetch; }++ });++const { preview, _cache } = require('../server/social/preview');const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };const calls = [];
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;
for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;
return 'just now';
return 'just now';
};
};
- const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));
+ const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon', 'openvibe'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));
/** Pills: one line of links (the offline screen). */
/** Pills: one line of links (the offline screen). */
function pills(host, links) {
function pills(host, links) {
@@ -105,7 +105,7 @@
@@ -105,7 +105,7 @@
<label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>
<label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>
${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `
${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `
<label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>
<label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>
- <i class="${escH(c.icon)}"></i> Show your connected ${escH(c.name)} (${escH(label(c))})</label>`).join('')}</div>` : ''}
+ <i class="${escH(c.icon)}"></i> ${c.kind === 'openvibe' ? 'Show your OpenVibe profile' : `Show your connected ${escH(c.name)}`} (${escH(label(c))})</label>`).join('')}</div>` : ''}
${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}
${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}
<div class="ch-social-rows">${s.links.map((l, i) => `
<div class="ch-social-rows">${s.links.map((l, i) => `
<div class="ch-social-row" data-i="${i}">
<div class="ch-social-row" data-i="${i}">
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
*
*
* cleanSocialLinks(input) → the list to store, or null when it is not a valid list
* cleanSocialLinks(input) → the list to store, or null when it is not a valid list
* channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already
* channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already
- * listed and not hidden (channels.social_links.hidden_auto)
+ * listed and not hidden (channels.social_links.hidden_auto), then the
+ * streamer's OpenVibe profile (openvibe.network/@username), also hideable
*/
*/
const MAX_LINKS = 16;
const MAX_LINKS = 16;
@@ -32,10 +33,12 @@ const PLATFORMS = {
@@ -32,10 +33,12 @@ const PLATFORMS = {
soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },
soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },
linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },
linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },
mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },
mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },
+ openvibe: { name: 'OpenVibe', icon: 'fa-solid fa-id-card', color: '#3b82f6', hosts: ['openvibe.network'], url: (h) => `https://openvibe.network/@${h}`, handle: /^\/@([A-Za-z0-9_]{3,24})\/?$/ },
website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },
website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },
custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },
custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },
};
};
-const AUTO_KINDS = ['twitch', 'youtube', 'kick'];
+// Shown without being added: the restream platforms a channel is connected to, and its OpenVibe profile.
+const AUTO_KINDS = ['twitch', 'youtube', 'kick', 'openvibe'];
const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));
const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));
@@ -127,6 +130,15 @@ async function channelSocialLinks(channel, db, { owner = false } = {}) {
@@ -127,6 +130,15 @@ async function channelSocialLinks(channel, db, { owner = false } = {}) {
auto.push(d);
auto.push(d);
if (!d.hidden) { out.push(d); kinds.add(c.platform); }
if (!d.hidden) { out.push(d); kinds.add(c.platform); }
}
}
+ // The streamer's OpenVibe profile (plan T21): their picture, bio and the items they wear, one card on every channel.
+ if (channel && channel.username && !kinds.has('openvibe')) {
+ const l = cleanLink({ kind: 'openvibe', handle: channel.username });
+ if (l) {
+ const d = decorate(l, { auto: true, hidden: stored.hidden_auto.includes('openvibe') });
+ auto.push(d);
+ if (!d.hidden) { out.push(d); kinds.add('openvibe'); }
+ }
+ }
const result = { links: out };
const result = { links: out };
if (owner) {
if (owner) {
let dests = [];
let dests = [];
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
*
*
* Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub
* Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub
* (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the
* (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the
- * channel is offline), Kick (its public channel API, best effort), and OpenGraph for everything else. X has no
+ * channel is offline), Kick (its public channel API, best effort), the streamer's OpenVibe profile (the Network's
+ * /api/v1/profiles, on loopback), and OpenGraph for everything else. X has no
* usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated
* usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated
* frame, /embed/x-timeline).
* frame, /embed/x-timeline).
*/
*/
@@ -34,6 +35,7 @@ async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/
@@ -34,6 +35,7 @@ async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/
const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+const RARITY = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
/** OpenGraph / meta tags of a page. */
/** OpenGraph / meta tags of a page. */
@@ -124,6 +126,27 @@ const ADAPTERS = {
@@ -124,6 +126,27 @@ const ADAPTERS = {
items: [],
items: [],
};
};
},
},
+ async openvibe(l) {
+ // The streamer's OpenVibe profile, from the Network's public profile API (ours, on loopback; the handle is a
+ // checked username): their picture, how many items they own and what they wear.
+ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ const r = await fetch(`${base}/api/v1/profiles/${encodeURIComponent(l.handle)}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
+ if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
+ const p = (await r.json()).profile || {};
+ const title = clip(p.display_name || `@${l.handle}`, 80);
+ if (p.private) return { title, subtitle: 'Profile on OpenVibe', image: httpsOnly(p.avatar_url), items: [] };
+ const n = p.items && !p.items.unavailable ? Number(p.items.count) || 0 : null;
+ const since = /^\d{4}/.test(String(p.member_since || '')) ? `on OpenVibe since ${String(p.member_since).slice(0, 4)}` : null;
+ return {
+ title,
+ subtitle: [n === null ? null : `${n.toLocaleString('en-US')}${p.items.more ? '+' : ''} item${n === 1 ? '' : 's'}`, since].filter(Boolean).join(' · ') || 'Profile on OpenVibe',
+ image: httpsOnly(p.avatar_url),
+ items: (Array.isArray(p.showcase) ? p.showcase : []).slice(0, 3).map((s) => ({
+ text: clip(`${s.art && s.art.emoji ? `${String(s.art.emoji).slice(0, 8)} ` : ''}Wearing ${s.name} (${RARITY[s.rarity] || 'Common'} ${String(s.kind_name || '').toLowerCase()})`, 120),
+ url: httpsOnly(s.url),
+ })),
+ };
+ },
async x(l) {
async x(l) {
// No free API: the card links out; the page offers X's own embed on click.
// No free API: the card links out; the page offers X's own embed on click.
return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };
return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };
@@ -138,7 +161,7 @@ async function preview(l) {
@@ -138,7 +161,7 @@ async function preview(l) {
let value;
let value;
try {
try {
const fn = ADAPTERS[l.kind] || (async (x) => await openGraph(x.url));
const fn = ADAPTERS[l.kind] || (async (x) => await openGraph(x.url));
- const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x'].includes(l.kind);
+ const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x', 'openvibe'].includes(l.kind);
value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);
value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);
value = { kind: l.kind, url: l.url, items: [], ...value };
value = { kind: l.kind, url: l.url, items: [], ...value };
remember(key, value, TTL);
remember(key, value, TTL);
@@ -345,6 +345,7 @@ async function check(name, fn) {
@@ -345,6 +345,7 @@ async function check(name, fn) {
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
+ 'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
@@ -46,6 +46,39 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
@@ -46,6 +46,39 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
assert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);
assert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);
});
});
+ await check('the OpenVibe profile: every channel shows openvibe.network/@username after its other links, unless listed or hidden', async () => {
+ const db = { all: () => [] };
+ const shown = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'x', handle: 'goosely' }] }) }, db, { owner: true });
+ assert.deepStrictEqual(shown.links.map((l) => [l.kind, l.url, !!l.auto]), [['x', 'https://x.com/goosely', false], ['openvibe', 'https://openvibe.network/@Goosely', true]]);
+ assert.deepStrictEqual(shown.connected.map((c) => [c.kind, c.hidden]), [['openvibe', false]], 'the editor can hide it');
+ const hidden = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [], hidden_auto: ['openvibe'] }) }, db);
+ assert.deepStrictEqual(hidden.links, []);
+ const listed = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'custom', url: 'https://openvibe.network/@Goosely' }] }) }, db);
+ assert.deepStrictEqual(listed.links.map((l) => [l.kind, !!l.auto]), [['openvibe', false]], 'listed by hand: once');
+ assert.strictEqual(cleanLink({ kind: 'openvibe', url: 'https://evil.example/@x' }), null);
+ });
+
+ await check('the OpenVibe profile preview: the Network\'s profile API, the picture, item count and what they wear; a private one says so', async () => {
+ const { preview: pv } = require('../server/social/preview');
+ const realFetch = global.fetch;
+ const seen = [];
+ global.fetch = async (url) => {
+ seen.push(String(url));
+ const name = decodeURIComponent(String(url).split('/').pop());
+ if (name === 'Goosely') return { ok: true, json: async () => ({ profile: { username: 'Goosely', display_name: 'Goosely', avatar_url: 'https://openvibe.network/avatar/Goosely?s=160', private: false, member_since: '2025-03-14', items: { count: 12, more: false }, showcase: [{ name: 'Void Crown', rarity: 'legendary', kind_name: 'Hat', art: { emoji: '🕳️' }, url: 'https://inventory.openvibe.network/items/itd_1' }] } }) };
+ if (name === 'quiet') return { ok: true, json: async () => ({ profile: { username: 'quiet', display_name: 'Quiet', avatar_url: 'https://openvibe.network/avatar/quiet?s=160', private: true } }) };
+ return { ok: false, status: 404, json: async () => ({}) };
+ };
+ try {
+ const p = await pv(cleanLink({ kind: 'openvibe', handle: 'Goosely' }));
+ assert.ok(seen[0].endsWith('/api/v1/profiles/Goosely') && seen[0].startsWith('http://127.0.0.1:4000'), 'loopback Network, fixed path');
+ assert.deepStrictEqual([p.title, p.subtitle, p.image], ['Goosely', '12 items · on OpenVibe since 2025', 'https://openvibe.network/avatar/Goosely?s=160']);
+ assert.deepStrictEqual(p.items, [{ text: '🕳️ Wearing Void Crown (Legendary hat)', url: 'https://inventory.openvibe.network/items/itd_1' }]);
+ const q = await pv(cleanLink({ kind: 'openvibe', handle: 'quiet' }));
+ assert.deepStrictEqual([q.title, q.subtitle, q.items], ['Quiet', 'Profile on OpenVibe', []]);
+ } finally { global.fetch = realFetch; }
+ });
+
const { preview, _cache } = require('../server/social/preview');
const { preview, _cache } = require('../server/social/preview');
const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };
const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };
const calls = [];
const calls = [];