openvibers/openvibe.chat Commits
Chat: OpenVibe's own links are preview kinds (#486)
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'];++const KINDS = ['link', 'video', 'image', 'live'];const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';@@@ -198,6 +198,96 @@ function oembedFor(url) {return null;}++/**++ * OpenVibe's own sites are first-class kinds and are never page-fetched: an address there is resolved through that++ * service's public API, or its oEmbed where it has one. Windows whose service has no resolver yet return null (and++ * are not page-fetched either): that is the list of kinds a later part adds.++ */++const OWN_HOSTS = new Map([++ ['openvibe.live', 'live'], ['openvibe.video', 'video'], ['openvibe.pics', 'picture'],++ ['openvibe.design', 'design'], ['openvibe.community', 'post'], ['openvibe.network', 'profile'],++ ['openvibe.games', 'game'],++]);++++function baseOf(value, production) {++ const raw = String(value || (process.env.NODE_ENV === 'test' ? '' : production)).replace(/\/+$/, '');++ if (!raw) return '';++ try {++ const u = new URL(raw);++ 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');++++/** 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) {++ const host = u.hostname.toLowerCase().replace(/^www\./, '');++ if (!OWN_HOSTS.has(host)) return null;++ const site = OWN_HOSTS.get(host);++ if (site === 'live') {++ const m = /^\/@([A-Za-z0-9_]{3,24})(?:\/[^/]+)?\/?$/.exec(u.pathname);++ return m ? { site, username: m[1] } : { site };++ }++ if (site === 'video') {++ if (u.pathname === '/watch' && /^[A-Za-z0-9_-]{11}$/.test(String(u.searchParams.get('v') || ''))) return { site, page: u.href };++ if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };++ return { site };++ }++ return { site };++}++++async function readJsonUrl(url) {++ const res = await fetchFollowing(url);++ if (!res) return null;++ try { return JSON.parse(String(res.body || '')); } catch { return null; }++}++++/** A live channel (/@name, no page fetch): Live's public channel API says who and whether they are live now. */++async function livePreview(url, username) {++ const base = liveBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/streams/channel/${encodeURIComponent(username)}?pollOnly=1`);++ const channel = item && item.channel;++ if (!channel || typeof channel !== 'object') return null;++ const title = textOf(channel.display_name || channel.username || username, MAX_TITLE);++ if (!title) return null;++ return {++ url,++ site_name: 'OpenVibe.Live',++ title,++ description: textOf(channel.bio, MAX_DESCRIPTION),++ image: imageOf(channel.avatar_url),++ kind: 'live',++ width: 0,++ height: 0,++ by: textOf(channel.username || username, 80),++ live: Array.isArray(item.streams) ? item.streams.length > 0 : !!item.stream,++ };++}++++/** A Video watch page (no page fetch): Video's own watchable resolver. */++async function videoPreview(url, page) {++ const base = videoBase();++ if (!base) return null;++ const item = await readJsonUrl(`${base}/api/v1/video/watchable?url=${encodeURIComponent(page)}`);++ if (!item || typeof item !== 'object') return null;++ const title = textOf(item.title, MAX_TITLE);++ if (!title) return null;++ return {++ url,++ site_name: 'OpenVibe.Video',++ title,++ description: '',++ image: imageOf(item.thumbnail_url),++ kind: 'video',++ width: 0,++ height: 0,++ by: textOf(item.channel, 80),++ live: false,++ };++}++/** 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));@@@ -259,6 +349,12 @@ async function fetchFollowing(url) {/** Read one address: oEmbed when it is on the short allow-list, the page otherwise. A failure is no preview. */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);++ return null;++ }const oembed = oembedFor(new URL(url));if (oembed) {try {@@@ -281,7 +377,7 @@ function parsePreview(value) {if (typeof p === 'string') { try { p = JSON.parse(p); } catch { return null; } }if (!p || typeof p !== 'object' || Array.isArray(p)) return null;if (!KINDS.includes(p.kind) || typeof p.title !== 'string' || !p.title || typeof p.url !== 'string') return null;-- return {++ const preview = {url: p.url,site_name: textOf(p.site_name, MAX_SITE),title: textOf(p.title, MAX_TITLE),@@@ -291,6 +387,9 @@ function parsePreview(value) {width: intOf(p.width),height: intOf(p.height),};++ if (p.by != null) preview.by = textOf(p.by, 80);++ if (p.live === true || p.live === false) preview.live = p.live;++ return preview;}async function cached(value) {@@@ -435,7 +534,7 @@ async function attach(rows, scope = 'room') {module.exports = {addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,-- previewOf, schedule, embedsOf, attach, idle,++ previewOf, schedule, embedsOf, attach, idle, ownLink,CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,_setRequest(fn) { _request = fn || null; },_setLookup(fn) { _lookup = fn || null; },@@@ -137,4 +137,76 @@ t('three addresses at most, and a redirect is followed only once it is judged puassert.ok(calls.includes('https://93.184.216.34/target'));});++t('a Live channel is a live kind from its public API, with no page fetch', async () => {++ process.env.CHAT_LINK_LIVE_URL = 'https://93.184.216.34';++ const calls = [];++ previews._setRequest(async (url) => {++ calls.push(url);++ if (url.includes('/api/streams/channel/streamer')) {++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ channel: { username: 'streamer', display_name: 'The Streamer', bio: 'Live every Friday', avatar_url: 'https://openvibe.media/avatars/s.jpg' },++ streams: [{ id: 7 }],++ }) };++ }++ return { status: 404, headers: {}, body: '' };++ });++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Live kinds' } });++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'watch https://openvibe.live/@streamer tonight' } });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];++ assert.strictEqual(embed.kind, 'live');++ assert.strictEqual(embed.title, 'The Streamer');++ assert.strictEqual(embed.by, 'streamer');++ assert.strictEqual(embed.live, true);++ assert.strictEqual(embed.image, 'https://openvibe.media/avatars/s.jpg');++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/streams/channel/streamer?pollOnly=1'], 'the page itself is never fetched');++});++++t('an offline Live channel is the same kind with live: false', async () => {++ previews._setRequest(async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({++ channel: { username: 'quiet', display_name: 'Quiet Corner' }, streams: [],++ }) }));++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Offline kind' } });++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.live/@quiet' } });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];++ assert.strictEqual(embed.kind, 'live');++ assert.strictEqual(embed.live, false);++});++++t('a Video watch page is resolved by Video and never page-fetched', async () => {++ process.env.CHAT_LINK_VIDEO_URL = 'https://93.184.216.34';++ const calls = [];++ previews._setRequest(async (url) => {++ calls.push(url);++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Friday film', channel: 'ann', thumbnail_url: 'https://openvibe.media/vod/friday.jpg', duration_s: 3725 }) };++ });++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Video kind' } });++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.video/watch?v=dQw4w9WgXcQ' } });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];++ assert.strictEqual(embed.kind, 'video');++ assert.strictEqual(embed.title, 'Friday film');++ assert.strictEqual(embed.by, 'ann');++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/v1/video/watchable?url=https%3A%2F%2Fopenvibe.video%2Fwatch%3Fv%3DdQw4w9WgXcQ']);++});++++t('an own site with no resolver yet is neither page-fetched nor previewed', async () => {++ let called = false;++ previews._setRequest(async () => { called = true; return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' }; });++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Unreached kind' } });++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.pics/p/some-picture' } });++ assert.strictEqual(sent.status, 201, sent.text);++ await previews.idle();++ assert.strictEqual(called, false);++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);++});++t.run(async () => { previews._reset(); await h.close(); });
@@ -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'];
+const KINDS = ['link', 'video', 'image', 'live'];
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';
@@ -198,6 +198,96 @@ function oembedFor(url) {
@@ -198,6 +198,96 @@ function oembedFor(url) {
return null;
return null;
}
}
+/**
+ * OpenVibe's own sites are first-class kinds and are never page-fetched: an address there is resolved through that
+ * service's public API, or its oEmbed where it has one. Windows whose service has no resolver yet return null (and
+ * are not page-fetched either): that is the list of kinds a later part adds.
+ */
+const OWN_HOSTS = new Map([
+ ['openvibe.live', 'live'], ['openvibe.video', 'video'], ['openvibe.pics', 'picture'],
+ ['openvibe.design', 'design'], ['openvibe.community', 'post'], ['openvibe.network', 'profile'],
+ ['openvibe.games', 'game'],
+]);
+
+function baseOf(value, production) {
+ const raw = String(value || (process.env.NODE_ENV === 'test' ? '' : production)).replace(/\/+$/, '');
+ if (!raw) return '';
+ try {
+ const u = new URL(raw);
+ 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');
+
+/** 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) {
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
+ if (!OWN_HOSTS.has(host)) return null;
+ const site = OWN_HOSTS.get(host);
+ if (site === 'live') {
+ const m = /^\/@([A-Za-z0-9_]{3,24})(?:\/[^/]+)?\/?$/.exec(u.pathname);
+ return m ? { site, username: m[1] } : { site };
+ }
+ if (site === 'video') {
+ if (u.pathname === '/watch' && /^[A-Za-z0-9_-]{11}$/.test(String(u.searchParams.get('v') || ''))) return { site, page: u.href };
+ if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };
+ return { site };
+ }
+ return { site };
+}
+
+async function readJsonUrl(url) {
+ const res = await fetchFollowing(url);
+ if (!res) return null;
+ try { return JSON.parse(String(res.body || '')); } catch { return null; }
+}
+
+/** A live channel (/@name, no page fetch): Live's public channel API says who and whether they are live now. */
+async function livePreview(url, username) {
+ const base = liveBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/streams/channel/${encodeURIComponent(username)}?pollOnly=1`);
+ const channel = item && item.channel;
+ if (!channel || typeof channel !== 'object') return null;
+ const title = textOf(channel.display_name || channel.username || username, MAX_TITLE);
+ if (!title) return null;
+ return {
+ url,
+ site_name: 'OpenVibe.Live',
+ title,
+ description: textOf(channel.bio, MAX_DESCRIPTION),
+ image: imageOf(channel.avatar_url),
+ kind: 'live',
+ width: 0,
+ height: 0,
+ by: textOf(channel.username || username, 80),
+ live: Array.isArray(item.streams) ? item.streams.length > 0 : !!item.stream,
+ };
+}
+
+/** A Video watch page (no page fetch): Video's own watchable resolver. */
+async function videoPreview(url, page) {
+ const base = videoBase();
+ if (!base) return null;
+ const item = await readJsonUrl(`${base}/api/v1/video/watchable?url=${encodeURIComponent(page)}`);
+ if (!item || typeof item !== 'object') return null;
+ const title = textOf(item.title, MAX_TITLE);
+ if (!title) return null;
+ return {
+ url,
+ site_name: 'OpenVibe.Video',
+ title,
+ description: '',
+ image: imageOf(item.thumbnail_url),
+ kind: 'video',
+ width: 0,
+ height: 0,
+ by: textOf(item.channel, 80),
+ live: false,
+ };
+}
+
/** 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));
@@ -259,6 +349,12 @@ async function fetchFollowing(url) {
@@ -259,6 +349,12 @@ async function fetchFollowing(url) {
/** Read one address: oEmbed when it is on the short allow-list, the page otherwise. A failure is no preview. */
/** Read one address: oEmbed when it is on the short allow-list, the page otherwise. A failure is no preview. */
async function readUrl(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);
+ return null;
+ }
const oembed = oembedFor(new URL(url));
const oembed = oembedFor(new URL(url));
if (oembed) {
if (oembed) {
try {
try {
@@ -281,7 +377,7 @@ function parsePreview(value) {
@@ -281,7 +377,7 @@ function parsePreview(value) {
if (typeof p === 'string') { try { p = JSON.parse(p); } catch { return null; } }
if (typeof p === 'string') { try { p = JSON.parse(p); } catch { return null; } }
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
if (!KINDS.includes(p.kind) || typeof p.title !== 'string' || !p.title || typeof p.url !== 'string') return null;
if (!KINDS.includes(p.kind) || typeof p.title !== 'string' || !p.title || typeof p.url !== 'string') return null;
- return {
+ const preview = {
url: p.url,
url: p.url,
site_name: textOf(p.site_name, MAX_SITE),
site_name: textOf(p.site_name, MAX_SITE),
title: textOf(p.title, MAX_TITLE),
title: textOf(p.title, MAX_TITLE),
@@ -291,6 +387,9 @@ function parsePreview(value) {
@@ -291,6 +387,9 @@ function parsePreview(value) {
width: intOf(p.width),
width: intOf(p.width),
height: intOf(p.height),
height: intOf(p.height),
};
};
+ if (p.by != null) preview.by = textOf(p.by, 80);
+ if (p.live === true || p.live === false) preview.live = p.live;
+ return preview;
}
}
async function cached(value) {
async function cached(value) {
@@ -435,7 +534,7 @@ async function attach(rows, scope = 'room') {
@@ -435,7 +534,7 @@ async function attach(rows, scope = 'room') {
module.exports = {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
- previewOf, schedule, embedsOf, attach, idle,
+ previewOf, schedule, embedsOf, attach, idle, ownLink,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
@@ -137,4 +137,76 @@ t('three addresses at most, and a redirect is followed only once it is judged pu
@@ -137,4 +137,76 @@ t('three addresses at most, and a redirect is followed only once it is judged pu
assert.ok(calls.includes('https://93.184.216.34/target'));
assert.ok(calls.includes('https://93.184.216.34/target'));
});
});
+t('a Live channel is a live kind from its public API, with no page fetch', async () => {
+ process.env.CHAT_LINK_LIVE_URL = 'https://93.184.216.34';
+ const calls = [];
+ previews._setRequest(async (url) => {
+ calls.push(url);
+ if (url.includes('/api/streams/channel/streamer')) {
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ channel: { username: 'streamer', display_name: 'The Streamer', bio: 'Live every Friday', avatar_url: 'https://openvibe.media/avatars/s.jpg' },
+ streams: [{ id: 7 }],
+ }) };
+ }
+ return { status: 404, headers: {}, body: '' };
+ });
+ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Live kinds' } });
+ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'watch https://openvibe.live/@streamer tonight' } });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
+ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
+ assert.strictEqual(embed.kind, 'live');
+ assert.strictEqual(embed.title, 'The Streamer');
+ assert.strictEqual(embed.by, 'streamer');
+ assert.strictEqual(embed.live, true);
+ assert.strictEqual(embed.image, 'https://openvibe.media/avatars/s.jpg');
+ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/streams/channel/streamer?pollOnly=1'], 'the page itself is never fetched');
+});
+
+t('an offline Live channel is the same kind with live: false', async () => {
+ previews._setRequest(async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
+ channel: { username: 'quiet', display_name: 'Quiet Corner' }, streams: [],
+ }) }));
+ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Offline kind' } });
+ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.live/@quiet' } });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
+ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
+ assert.strictEqual(embed.kind, 'live');
+ assert.strictEqual(embed.live, false);
+});
+
+t('a Video watch page is resolved by Video and never page-fetched', async () => {
+ process.env.CHAT_LINK_VIDEO_URL = 'https://93.184.216.34';
+ const calls = [];
+ previews._setRequest(async (url) => {
+ calls.push(url);
+ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Friday film', channel: 'ann', thumbnail_url: 'https://openvibe.media/vod/friday.jpg', duration_s: 3725 }) };
+ });
+ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Video kind' } });
+ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.video/watch?v=dQw4w9WgXcQ' } });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
+ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
+ assert.strictEqual(embed.kind, 'video');
+ assert.strictEqual(embed.title, 'Friday film');
+ assert.strictEqual(embed.by, 'ann');
+ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/v1/video/watchable?url=https%3A%2F%2Fopenvibe.video%2Fwatch%3Fv%3DdQw4w9WgXcQ']);
+});
+
+t('an own site with no resolver yet is neither page-fetched nor previewed', async () => {
+ let called = false;
+ previews._setRequest(async () => { called = true; return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' }; });
+ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Unreached kind' } });
+ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.pics/p/some-picture' } });
+ assert.strictEqual(sent.status, 201, sent.text);
+ await previews.idle();
+ assert.strictEqual(called, false);
+ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
+ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);
+});
+
t.run(async () => { previews._reset(); await h.close(); });
t.run(async () => { previews._reset(); await h.close(); });