openvibers/openvibe.chat Commits
Chat: Pics, Community, Network and Games links are preview kinds (#503)
Changes
@@@ -34,7 +34,7 @@ const MAX_DESCRIPTION = 300;const MAX_SITE = 80;const MAX_JOBS = 2;const MAX_QUEUE = 200;--const KINDS = ['link', 'video', 'image', 'live'];++const KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';@@@ -217,8 +217,13 @@ function baseOf(value, production) {return u.protocol === 'https:' || u.protocol === 'http:' ? raw : '';} catch { return ''; }}--const liveBase = () => baseOf(process.env.CHAT_LINK_LIVE_URL, 'https://openvibe.live');--const videoBase = () => baseOf(process.env.CHAT_LINK_VIDEO_URL, 'https://openvibe.video');++const baseFor = (name, production) => () => baseOf(process.env[name], production);++const liveBase = baseFor('CHAT_LINK_LIVE_URL', 'https://openvibe.live');++const videoBase = baseFor('CHAT_LINK_VIDEO_URL', 'https://openvibe.video');++const picsBase = baseFor('CHAT_LINK_PICS_URL', 'https://openvibe.pics');++const communityBase = baseFor('CHAT_LINK_COMMUNITY_URL', 'https://openvibe.community');++const networkBase = baseFor('CHAT_LINK_NETWORK_URL', 'https://openvibe.network');++const gamesBase = baseFor('CHAT_LINK_GAMES_URL', 'https://openvibe.games');/** What an address on one of our sites asks that site for, or null for host/path shapes we do not know. */function ownLink(u) {@@@ -234,6 +239,22 @@ function ownLink(u) {if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };return { site };}++ if (site === 'picture') {++ const m = /^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})(?:\.[A-Za-z0-9]+)?\/?$/.exec(u.pathname);++ return m ? { site, slug: m[1] } : { site };++ }++ if (site === 'post') {++ const m = /^\/s\/([^/]{1,80})\/t\/([^/]{1,200})\/?$/.exec(u.pathname);++ return m ? { site, space: decodeURIComponent(m[1]), thread: decodeURIComponent(m[2]) } : { site };++ }++ if (site === 'profile') {++ const m = /^\/@([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname);++ return m ? { site, username: m[1] } : { site };++ }++ if (site === 'game') {++ const m = /^\/worlds\/(w[0-9a-z]{10})\/?$/.exec(u.pathname);++ return m ? { site, world: m[1] } : { site };++ }return { site };}@@@ -288,6 +309,104 @@ async function videoPreview(url, page) {};}++/** A Pics picture (/i/<slug>, no page fetch): Pics' public image read. Its file lives on openvibe.pics, not Media,++ * so the picture itself is left out (a page's image is never hot-linked); the title and size still make a card. */++async function picturePreview(url, slug) {++ const base = picsBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/v1/images/${encodeURIComponent(slug)}`);++ if (!item || typeof item !== 'object' || item.state === 'removed') return null;++ const title = textOf(item.title || item.alt || 'A picture on OpenVibe.Pics', MAX_TITLE);++ if (!title) return null;++ return {++ url,++ site_name: 'OpenVibe.Pics',++ title,++ description: textOf(item.description || item.alt, MAX_DESCRIPTION),++ image: imageOf(item.links && item.links.direct),++ kind: 'picture',++ width: intOf(item.width),++ height: intOf(item.height),++ };++}++++/** A Community thread (/s/<space>/t/<slug>, no page fetch): Community's public thread read, with its first post. */++async function communityPreview(url, space, thread) {++ const base = communityBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/v1/spaces/${encodeURIComponent(space)}/threads/${encodeURIComponent(thread)}`);++ const found = item && item.thread;++ if (!found || typeof found !== 'object') return null;++ const title = textOf(found.title, MAX_TITLE);++ if (!title) return null;++ const first = Array.isArray(item.posts) && item.posts[0] ? item.posts[0] : null;++ const attachments = first && Array.isArray(first.attachments) ? first.attachments : [];++ const media = attachments.map((a) => imageOf(a && a.url)).find(Boolean) || null;++ const author = found.author || {};++ const by = textOf(author.username || author.display_name, 80);++ const preview = {++ url,++ site_name: 'OpenVibe.Community',++ title,++ description: textOf(first && first.body_markdown, MAX_DESCRIPTION),++ image: media,++ kind: 'post',++ width: 0,++ height: 0,++ };++ if (by) preview.by = by;++ return preview;++}++++/** A Network profile (/@name, no page fetch): Network's public profile read. Its avatar is served by Network, not++ * Media, so it is left out; the name and bio still make a card. */++async function profilePreview(url, username) {++ const base = networkBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/v1/profiles/${encodeURIComponent(username)}`);++ const profile = item && item.profile;++ if (!profile || typeof profile !== 'object') return null;++ const title = textOf(profile.display_name || profile.username || username, MAX_TITLE);++ if (!title) return null;++ const preview = {++ url,++ site_name: 'OpenVibe.Network',++ title,++ description: textOf(profile.bio, MAX_DESCRIPTION),++ image: imageOf(profile.avatar_url),++ kind: 'profile',++ width: 0,++ height: 0,++ };++ const by = textOf(profile.username || username, 80);++ if (by) preview.by = by;++ return preview;++}++++/** A Games creator world (/worlds/<id>, no page fetch): Games' public world read. The built-in Play titles have no++ * public game read of their own, so a /play address stays unpreviewed rather than read as a page. */++async function gamePreview(url, world) {++ const base = gamesBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/worlds/${encodeURIComponent(world)}`);++ if (!item || typeof item !== 'object') return null;++ const title = textOf(item.title, MAX_TITLE);++ if (!title) return null;++ const preview = {++ url,++ site_name: 'OpenVibe.Games',++ title,++ description: textOf(item.summary, MAX_DESCRIPTION),++ image: null,++ kind: 'game',++ width: 0,++ height: 0,++ };++ const by = textOf(item.owner, 80);++ if (by) preview.by = by;++ return preview;++}++/** One GET, https only, through the egress lookup, no redirects followed here, at most FETCH_TIMEOUT_MS and MAX_BYTES. */function requestOnce(url) {if (_request) return Promise.resolve(_request(url));@@@ -351,8 +470,12 @@ async function fetchFollowing(url) {async function readUrl(url) {const own = ownLink(new URL(url));if (own) {-- if (own.username) return await livePreview(url, own.username);-- if (own.page) return await videoPreview(url, own.page);++ if (own.site === 'live' && own.username) return await livePreview(url, own.username);++ if (own.site === 'video' && own.page) return await videoPreview(url, own.page);++ if (own.site === 'picture' && own.slug) return await picturePreview(url, own.slug);++ if (own.site === 'post' && own.space && own.thread) return await communityPreview(url, own.space, own.thread);++ if (own.site === 'profile' && own.username) return await profilePreview(url, own.username);++ if (own.site === 'game' && own.world) return await gamePreview(url, own.world);return null;}const oembed = oembedFor(new URL(url));@@@ -280,6 +280,80 @@ t('a moderator removes any preview; another member may not', async () => {assert.deepStrictEqual(removed.body.embeds, []);});++// ── Own kinds through each service's public API, never a page fetch ─────────────────────────────────────────++++t('Pics, Community and Network resolve through their public APIs, never their pages', async () => {++ process.env.CHAT_LINK_PICS_URL = 'https://93.184.216.34';++ process.env.CHAT_LINK_COMMUNITY_URL = 'https://93.184.216.34';++ process.env.CHAT_LINK_NETWORK_URL = 'https://93.184.216.34';++ previews._setLookup((host, options, callback) => callback(null, [{ address: '93.184.216.34', family: 4 }]));++ const calls = [];++ previews._setRequest(async (url) => {++ calls.push(url);++ if (url.includes('/api/v1/images/abcd2345')) {++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ title: 'Harbour at dusk', description: 'Boats and a red sky', width: 1200, height: 800, state: 'active',++ links: { direct: 'https://openvibe.pics/i/abcd2345.jpg' },++ }) };++ }++ if (url.includes('/api/v1/spaces/feedback/threads/hello-post')) {++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ thread: { title: 'A Community post', author: { username: 'alice', display_name: 'Alice' } },++ posts: [{ body_markdown: 'Hello **world**', attachments: [{ url: 'https://openvibe.media/att/1.jpg' }] }],++ }) };++ }++ if (url.includes('/api/v1/profiles/alice')) {++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ profile: { username: 'alice', display_name: 'Alice', bio: 'Builder of things', avatar_url: 'https://openvibe.network/avatar/alice?s=160' },++ }) };++ }++ return { status: 404, headers: {}, body: '' };++ });++ await new Promise((r) => setTimeout(r, 10500)); // the room's per-person posting budget++ const sent = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token, body: {++ message: 'https://openvibe.pics/i/abcd2345 https://openvibe.community/s/feedback/t/hello-post https://openvibe.network/@alice',++ } });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ const got = await h.http('GET', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token });++ const embeds = got.body.messages.find((m) => m.id === sent.body.message.id).embeds;++ assert.deepStrictEqual(embeds.map((e) => e.kind), ['picture', 'post', 'profile']);++ assert.strictEqual(embeds[0].title, 'Harbour at dusk');++ assert.strictEqual(embeds[0].width, 1200);++ assert.strictEqual(embeds[0].image, null, 'a Pics file is not on Media: never hot-linked');++ assert.strictEqual(embeds[1].title, 'A Community post');++ assert.strictEqual(embeds[1].by, 'alice');++ assert.strictEqual(embeds[1].image, 'https://openvibe.media/att/1.jpg');++ assert.strictEqual(embeds[2].title, 'Alice');++ assert.strictEqual(embeds[2].by, 'alice');++ assert.strictEqual(embeds[2].image, null, 'a Network avatar is not on Media: left out');++ assert.deepStrictEqual(calls, [++ 'https://93.184.216.34/api/v1/images/abcd2345',++ 'https://93.184.216.34/api/v1/spaces/feedback/threads/hello-post',++ 'https://93.184.216.34/api/v1/profiles/alice',++ ], 'each service is asked through its API, never its page');++});++++t('a Games creator world resolves through the public API; a built-in Play title has no public read and is not fetched', async () => {++ process.env.CHAT_LINK_GAMES_URL = 'https://93.184.216.34';++ const calls = [];++ previews._setRequest(async (url) => {++ calls.push(url);++ if (url.includes('/api/worlds/w0123456789')) {++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ id: 'w0123456789', title: 'Sky world', summary: 'Build in the clouds', owner: 'alice', plays: 12,++ }) };++ }++ return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' };++ });++ const world = await previews.previewOf('https://openvibe.games/worlds/w0123456789');++ assert.strictEqual(world.kind, 'game');++ assert.strictEqual(world.title, 'Sky world');++ assert.strictEqual(world.by, 'alice');++ assert.strictEqual(await previews.previewOf('https://openvibe.games/play?game=scraplandia'), null, 'the built-in Play title has no public game read');++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/worlds/w0123456789'], 'the /play page is never read');++});++t.run(async () => { previews._reset(); await h.close(); });// ── Part 4: privacy in direct messages ───────────────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ const MAX_DESCRIPTION = 300;
@@ -34,7 +34,7 @@ const MAX_DESCRIPTION = 300;
const MAX_SITE = 80;
const MAX_SITE = 80;
const MAX_JOBS = 2;
const MAX_JOBS = 2;
const MAX_QUEUE = 200;
const MAX_QUEUE = 200;
-const KINDS = ['link', 'video', 'image', 'live'];
+const KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';
const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';
const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';
const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';
@@ -217,8 +217,13 @@ function baseOf(value, production) {
@@ -217,8 +217,13 @@ function baseOf(value, production) {
return u.protocol === 'https:' || u.protocol === 'http:' ? raw : '';
return u.protocol === 'https:' || u.protocol === 'http:' ? raw : '';
} catch { return ''; }
} catch { return ''; }
}
}
-const liveBase = () => baseOf(process.env.CHAT_LINK_LIVE_URL, 'https://openvibe.live');
-const videoBase = () => baseOf(process.env.CHAT_LINK_VIDEO_URL, 'https://openvibe.video');
+const baseFor = (name, production) => () => baseOf(process.env[name], production);
+const liveBase = baseFor('CHAT_LINK_LIVE_URL', 'https://openvibe.live');
+const videoBase = baseFor('CHAT_LINK_VIDEO_URL', 'https://openvibe.video');
+const picsBase = baseFor('CHAT_LINK_PICS_URL', 'https://openvibe.pics');
+const communityBase = baseFor('CHAT_LINK_COMMUNITY_URL', 'https://openvibe.community');
+const networkBase = baseFor('CHAT_LINK_NETWORK_URL', 'https://openvibe.network');
+const gamesBase = baseFor('CHAT_LINK_GAMES_URL', 'https://openvibe.games');
/** What an address on one of our sites asks that site for, or null for host/path shapes we do not know. */
/** What an address on one of our sites asks that site for, or null for host/path shapes we do not know. */
function ownLink(u) {
function ownLink(u) {
@@ -234,6 +239,22 @@ function ownLink(u) {
@@ -234,6 +239,22 @@ function ownLink(u) {
if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };
if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };
return { site };
return { site };
}
}
+ if (site === 'picture') {
+ const m = /^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})(?:\.[A-Za-z0-9]+)?\/?$/.exec(u.pathname);
+ return m ? { site, slug: m[1] } : { site };
+ }
+ if (site === 'post') {
+ const m = /^\/s\/([^/]{1,80})\/t\/([^/]{1,200})\/?$/.exec(u.pathname);
+ return m ? { site, space: decodeURIComponent(m[1]), thread: decodeURIComponent(m[2]) } : { site };
+ }
+ if (site === 'profile') {
+ const m = /^\/@([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname);
+ return m ? { site, username: m[1] } : { site };
+ }
+ if (site === 'game') {
+ const m = /^\/worlds\/(w[0-9a-z]{10})\/?$/.exec(u.pathname);
+ return m ? { site, world: m[1] } : { site };
+ }
return { site };
return { site };
}
}
@@ -288,6 +309,104 @@ async function videoPreview(url, page) {
@@ -288,6 +309,104 @@ async function videoPreview(url, page) {
};
};
}
}
+/** A Pics picture (/i/<slug>, no page fetch): Pics' public image read. Its file lives on openvibe.pics, not Media,
+ * so the picture itself is left out (a page's image is never hot-linked); the title and size still make a card. */
+async function picturePreview(url, slug) {
+ const base = picsBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/v1/images/${encodeURIComponent(slug)}`);
+ if (!item || typeof item !== 'object' || item.state === 'removed') return null;
+ const title = textOf(item.title || item.alt || 'A picture on OpenVibe.Pics', MAX_TITLE);
+ if (!title) return null;
+ return {
+ url,
+ site_name: 'OpenVibe.Pics',
+ title,
+ description: textOf(item.description || item.alt, MAX_DESCRIPTION),
+ image: imageOf(item.links && item.links.direct),
+ kind: 'picture',
+ width: intOf(item.width),
+ height: intOf(item.height),
+ };
+}
+
+/** A Community thread (/s/<space>/t/<slug>, no page fetch): Community's public thread read, with its first post. */
+async function communityPreview(url, space, thread) {
+ const base = communityBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/v1/spaces/${encodeURIComponent(space)}/threads/${encodeURIComponent(thread)}`);
+ const found = item && item.thread;
+ if (!found || typeof found !== 'object') return null;
+ const title = textOf(found.title, MAX_TITLE);
+ if (!title) return null;
+ const first = Array.isArray(item.posts) && item.posts[0] ? item.posts[0] : null;
+ const attachments = first && Array.isArray(first.attachments) ? first.attachments : [];
+ const media = attachments.map((a) => imageOf(a && a.url)).find(Boolean) || null;
+ const author = found.author || {};
+ const by = textOf(author.username || author.display_name, 80);
+ const preview = {
+ url,
+ site_name: 'OpenVibe.Community',
+ title,
+ description: textOf(first && first.body_markdown, MAX_DESCRIPTION),
+ image: media,
+ kind: 'post',
+ width: 0,
+ height: 0,
+ };
+ if (by) preview.by = by;
+ return preview;
+}
+
+/** A Network profile (/@name, no page fetch): Network's public profile read. Its avatar is served by Network, not
+ * Media, so it is left out; the name and bio still make a card. */
+async function profilePreview(url, username) {
+ const base = networkBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/v1/profiles/${encodeURIComponent(username)}`);
+ const profile = item && item.profile;
+ if (!profile || typeof profile !== 'object') return null;
+ const title = textOf(profile.display_name || profile.username || username, MAX_TITLE);
+ if (!title) return null;
+ const preview = {
+ url,
+ site_name: 'OpenVibe.Network',
+ title,
+ description: textOf(profile.bio, MAX_DESCRIPTION),
+ image: imageOf(profile.avatar_url),
+ kind: 'profile',
+ width: 0,
+ height: 0,
+ };
+ const by = textOf(profile.username || username, 80);
+ if (by) preview.by = by;
+ return preview;
+}
+
+/** A Games creator world (/worlds/<id>, no page fetch): Games' public world read. The built-in Play titles have no
+ * public game read of their own, so a /play address stays unpreviewed rather than read as a page. */
+async function gamePreview(url, world) {
+ const base = gamesBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/worlds/${encodeURIComponent(world)}`);
+ if (!item || typeof item !== 'object') return null;
+ const title = textOf(item.title, MAX_TITLE);
+ if (!title) return null;
+ const preview = {
+ url,
+ site_name: 'OpenVibe.Games',
+ title,
+ description: textOf(item.summary, MAX_DESCRIPTION),
+ image: null,
+ kind: 'game',
+ width: 0,
+ height: 0,
+ };
+ const by = textOf(item.owner, 80);
+ if (by) preview.by = by;
+ return preview;
+}
+
/** One GET, https only, through the egress lookup, no redirects followed here, at most FETCH_TIMEOUT_MS and MAX_BYTES. */
/** One GET, https only, through the egress lookup, no redirects followed here, at most FETCH_TIMEOUT_MS and MAX_BYTES. */
function requestOnce(url) {
function requestOnce(url) {
if (_request) return Promise.resolve(_request(url));
if (_request) return Promise.resolve(_request(url));
@@ -351,8 +470,12 @@ async function fetchFollowing(url) {
@@ -351,8 +470,12 @@ async function fetchFollowing(url) {
async function readUrl(url) {
async function readUrl(url) {
const own = ownLink(new URL(url));
const own = ownLink(new URL(url));
if (own) {
if (own) {
- if (own.username) return await livePreview(url, own.username);
- if (own.page) return await videoPreview(url, own.page);
+ if (own.site === 'live' && own.username) return await livePreview(url, own.username);
+ if (own.site === 'video' && own.page) return await videoPreview(url, own.page);
+ if (own.site === 'picture' && own.slug) return await picturePreview(url, own.slug);
+ if (own.site === 'post' && own.space && own.thread) return await communityPreview(url, own.space, own.thread);
+ if (own.site === 'profile' && own.username) return await profilePreview(url, own.username);
+ if (own.site === 'game' && own.world) return await gamePreview(url, own.world);
return null;
return null;
}
}
const oembed = oembedFor(new URL(url));
const oembed = oembedFor(new URL(url));
@@ -280,6 +280,80 @@ t('a moderator removes any preview; another member may not', async () => {
@@ -280,6 +280,80 @@ t('a moderator removes any preview; another member may not', async () => {
assert.deepStrictEqual(removed.body.embeds, []);
assert.deepStrictEqual(removed.body.embeds, []);
});
});
+// ── Own kinds through each service's public API, never a page fetch ─────────────────────────────────────────
+
+t('Pics, Community and Network resolve through their public APIs, never their pages', async () => {
+ process.env.CHAT_LINK_PICS_URL = 'https://93.184.216.34';
+ process.env.CHAT_LINK_COMMUNITY_URL = 'https://93.184.216.34';
+ process.env.CHAT_LINK_NETWORK_URL = 'https://93.184.216.34';
+ previews._setLookup((host, options, callback) => callback(null, [{ address: '93.184.216.34', family: 4 }]));
+ const calls = [];
+ previews._setRequest(async (url) => {
+ calls.push(url);
+ if (url.includes('/api/v1/images/abcd2345')) {
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ title: 'Harbour at dusk', description: 'Boats and a red sky', width: 1200, height: 800, state: 'active',
+ links: { direct: 'https://openvibe.pics/i/abcd2345.jpg' },
+ }) };
+ }
+ if (url.includes('/api/v1/spaces/feedback/threads/hello-post')) {
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ thread: { title: 'A Community post', author: { username: 'alice', display_name: 'Alice' } },
+ posts: [{ body_markdown: 'Hello **world**', attachments: [{ url: 'https://openvibe.media/att/1.jpg' }] }],
+ }) };
+ }
+ if (url.includes('/api/v1/profiles/alice')) {
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ profile: { username: 'alice', display_name: 'Alice', bio: 'Builder of things', avatar_url: 'https://openvibe.network/avatar/alice?s=160' },
+ }) };
+ }
+ return { status: 404, headers: {}, body: '' };
+ });
+ await new Promise((r) => setTimeout(r, 10500)); // the room's per-person posting budget
+ const sent = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token, body: {
+ message: 'https://openvibe.pics/i/abcd2345 https://openvibe.community/s/feedback/t/hello-post https://openvibe.network/@alice',
+ } });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ const got = await h.http('GET', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token });
+ const embeds = got.body.messages.find((m) => m.id === sent.body.message.id).embeds;
+ assert.deepStrictEqual(embeds.map((e) => e.kind), ['picture', 'post', 'profile']);
+ assert.strictEqual(embeds[0].title, 'Harbour at dusk');
+ assert.strictEqual(embeds[0].width, 1200);
+ assert.strictEqual(embeds[0].image, null, 'a Pics file is not on Media: never hot-linked');
+ assert.strictEqual(embeds[1].title, 'A Community post');
+ assert.strictEqual(embeds[1].by, 'alice');
+ assert.strictEqual(embeds[1].image, 'https://openvibe.media/att/1.jpg');
+ assert.strictEqual(embeds[2].title, 'Alice');
+ assert.strictEqual(embeds[2].by, 'alice');
+ assert.strictEqual(embeds[2].image, null, 'a Network avatar is not on Media: left out');
+ assert.deepStrictEqual(calls, [
+ 'https://93.184.216.34/api/v1/images/abcd2345',
+ 'https://93.184.216.34/api/v1/spaces/feedback/threads/hello-post',
+ 'https://93.184.216.34/api/v1/profiles/alice',
+ ], 'each service is asked through its API, never its page');
+});
+
+t('a Games creator world resolves through the public API; a built-in Play title has no public read and is not fetched', async () => {
+ process.env.CHAT_LINK_GAMES_URL = 'https://93.184.216.34';
+ const calls = [];
+ previews._setRequest(async (url) => {
+ calls.push(url);
+ if (url.includes('/api/worlds/w0123456789')) {
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ id: 'w0123456789', title: 'Sky world', summary: 'Build in the clouds', owner: 'alice', plays: 12,
+ }) };
+ }
+ return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' };
+ });
+ const world = await previews.previewOf('https://openvibe.games/worlds/w0123456789');
+ assert.strictEqual(world.kind, 'game');
+ assert.strictEqual(world.title, 'Sky world');
+ assert.strictEqual(world.by, 'alice');
+ assert.strictEqual(await previews.previewOf('https://openvibe.games/play?game=scraplandia'), null, 'the built-in Play title has no public game read');
+ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/worlds/w0123456789'], 'the /play page is never read');
+});
+
t.run(async () => { previews._reset(); await h.close(); });
t.run(async () => { previews._reset(); await h.close(); });
// ── Part 4: privacy in direct messages ───────────────────────────────────────────────────────────────────────
// ── Part 4: privacy in direct messages ───────────────────────────────────────────────────────────────────────