openvibers/openvibe.chat Commits

Known OpenVibe links in a message get a card with the one move they are for (#401)

OP OpenVibers committed 6 days ago · 3803d18ac2 · 17 files, +881 −41

* Known OpenVibe links in a message get a card with the one move they are for

A pasted video, watch party invite, shared voice or skill link now shows what it
is under the message, with the one move that link is for: Watch together, Join
the party, Borrow this agent, or Install.

- server/chat/link-cards.js resolves only exact hosts and paths. A video page
  goes to Video's /api/v1/video/watchable as a query parameter (never fetched
  itself); a party is Chat's own row, shown only when the link's k= matches; a
  voice or skill is Actor's own JSON under its fixed origin. No redirects,
  2.5 s, cached a minute per URL; a 404 or an unknown link is no card.
- The card is stored with the message (migration 0044: chat_messages,
  room_messages, dm_messages, inbox_messages) and re-resolved on read only
  once it is older than a day. Room, global, DM and inbox reads hydrate it.
- Rooms, global chat, DMs and the inbox draw it under the message; the DM API
  answer carries it for the Dock (Shared's rendering comes later). Card text is
  escaped and cut, and a picture is only https on openvibe.media.
- server/chat/link-cards.js is on the SSRF ratchet, with its reason.

Checked: test/link-cards.test.js with local Video and Actor stand-ins (each
kind, an unknown link, a private video, a party without k=, escaping and
cutting, the cache, and all four message stores); the full suite (113/116 on a
loaded machine, n-1, parity and message-deleted timing out at 60 s and passing
on their own); the SSRF ratchet and pg-schema.

* Sending never waits on Video or Actor; a page refreshes six stale cards at a time

Opus's review of #401: the egress and rendering stay as they are, two speed
changes.

- forSend() gives the resolver a 1 s budget. A ready card is stored with the
  message; a slower one is stored without and fillLater() writes the card into
  the row when it arrives, not awaited by the request. Used by the socket chat
  line, REST /send, rooms.post, dm.sendMessage and the inbox.
- hydrate() takes its table (chat_messages, room_messages, dm_messages,
  inbox_messages), refreshes at most six stale cards per call, and writes a
  refreshed card back. A network error keeps the stored card; a 404, a stopped
  voice or an ended party stores null.
- readJson() throws on a network error/timeout and returns null on a non-200,
  so the read path can tell them apart. The party lookup is no longer cached:
  it is Chat's own row, so an ended party drops its card at once.
- history-store hydrates copies, so its two-second page memo is never changed.

Checked: test/link-cards.test.js (a slow 2 s card sends in about 1 s and lands
on the row; a 100-message history makes six fetches; a refreshed card is
written back and the next read makes none; a network error keeps the card; a
404 and an ended party drop it), and the full Chat suite, 116/116.

Changes

ddiff --git a/migrations/0044_link_cards.sql b/migrations/0044_link_cards.sql
nnew file mode 100644
iindex 0000000..f0a449e
---- /dev/null
++++ b/migrations/0044_link_cards.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- A known OpenVibe link in a message shows a card with the one move it is for (plan HO: one account across every
++-- site; server/chat/link-cards.js). The card is JSON text, resolved when the message is sent:
++--
++-- { kind, url, title, line, note?, image, action: { label, href } | null, at }
++--
++-- Null for every message without a known link, and for every message saved before this release (a read never
++-- resolves a missing card: only a stored one older than a day is resolved again). Chat's own messages, room
++-- messages, direct messages and support-inbox messages all carry one.
++--
++-- Expand only: one nullable column per message table; an older release ignores it and its INSERTs (which name the
++-- column not at all) still fit.
++ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS card text COLLATE "C";
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS card text COLLATE "C";
++ALTER TABLE dm_messages ADD COLUMN IF NOT EXISTS card text COLLATE "C";
++ALTER TABLE inbox_messages ADD COLUMN IF NOT EXISTS card text COLLATE "C";
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 3588a92..d62d8d7 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -239,6 +239,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-move:hover { border-color: var(--oc-accent); }
/* Pictures in rooms and channels (server/rooms/pictures.js): thumbnails under a message, the "Add a picture" form. */
++/* A known OpenVibe link under its message (server/chat/link-cards.js): a picture at 16:9, the title, one line and
++ the one move as a button. Rounded and quiet: the message stays the thing that was said. */
++.oc-card { display: flex; flex-direction: column; max-width: 380px; margin: 6px 0 2px; border: 1px solid var(--oc-line); border-radius: 10px; overflow: hidden; background: var(--oc-panel-2); }
++.oc-card-pic { display: block; line-height: 0; }
++.oc-card-pic img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
++.oc-card-body { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; }
++.oc-card-title { font-weight: 600; line-height: 1.35; color: var(--oc-text); text-decoration: none; overflow-wrap: anywhere; }
++.oc-card-title:hover { text-decoration: underline; }
++.oc-card-line, .oc-card-note { margin: 0; color: var(--oc-muted); font-size: .86em; line-height: 1.4; overflow-wrap: anywhere; }
++.oc-card-go { align-self: flex-start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; background: var(--oc-accent); color: var(--oc-on-accent); text-decoration: none; font-weight: 600; font-size: .86em; }
++.oc-card-go:hover { filter: brightness(1.08); }
++.ovt-bubble .oc-card, .ovt-reply .oc-card { margin-top: 8px; }
.oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
.oc-pic { display: block; line-height: 0; border-radius: 10px; }
.oc-pic img { display: block; width: auto; max-width: min(320px, 100%); max-height: 240px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); object-fit: cover; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 8c49d78..230963b 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -91,6 +91,39 @@
}
if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
}
++ // A known OpenVibe link's card under its message (server/chat/link-cards.js, server/web/ui.js draws the same):
++ // the picture when there is one, the title, one line and the one move as a button. Every field is data: set with
++ // textContent, and a link or picture only when it is https (openvibe.media for the picture).
++ function httpsHref(v) {
++ try { var u = new URL(String(v)); return u.protocol === 'https:' ? u.href : null; } catch (e) { return null; }
++ }
++ function linkCard(c) {
++ if (!c || typeof c !== 'object' || typeof c.title !== 'string' || !c.title) return null;
++ var url = httpsHref(c.url), action = c.action && httpsHref(c.action.href);
++ var box = el('div', 'oc-card'); box.dataset.kind = typeof c.kind === 'string' ? c.kind : 'link';
++ var img = typeof c.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(c.image) ? httpsHref(c.image) : null;
++ if (img) {
++ var href = url || img;
++ var a = el('a', 'oc-card-pic'); a.href = href; a.target = '_blank'; a.rel = 'noopener nofollow';
++ var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
++ a.appendChild(picture); box.appendChild(a);
++ }
++ var body = el('div', 'oc-card-body');
++ if (url) {
++ var title = el('a', 'oc-card-title', c.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow';
++ body.appendChild(title);
++ } else body.appendChild(el('span', 'oc-card-title', c.title));
++ if (typeof c.line === 'string' && c.line) body.appendChild(el('p', 'oc-card-line', c.line));
++ if (typeof c.note === 'string' && c.note) body.appendChild(el('p', 'oc-card-note', c.note));
++ if (action && c.action && typeof c.action.label === 'string' && c.action.label) {
++ var go = el('a', 'oc-card-go', c.action.label); go.href = action; go.target = '_blank'; go.rel = 'noopener nofollow';
++ body.appendChild(go);
++ }
++ box.appendChild(body);
++ return box;
++ }
++ function addCard(li, m) { var c = linkCard(m && m.card); if (c) li.appendChild(c); }
++
// Display only (the server decides who may do what): the same badges the page renders.
var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
function badge(role) { var b = BADGES[role]; return b ? el('span', b[0], b[1]) : null; }
@@@ -147,6 +180,7 @@
var b = badge(m.role); if (b) li.appendChild(b);
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ addCard(li, m);
return li;
}
function removeIds(ids) {
@@@ -228,6 +262,7 @@
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ addCard(li, m);
return li;
}
function startDm() {
@@@ -307,6 +342,7 @@
else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
li.appendChild(t);
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
++ addCard(li, m);
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var x = el('button'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message'); x.innerHTML = TRASH;
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex a59741f..4e8562c 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -19,6 +19,35 @@
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
function chip(s) { return el('span', 'ib-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); }
++ // A known OpenVibe link's card (server/chat/link-cards.js; the same markup the server draws): picture, title, one
++ // line and the one move. Every field is data: textContent, and only https addresses become links or pictures.
++ function httpsHref(v) {
++ try { var u = new URL(String(v)); return u.protocol === 'https:' ? u.href : null; } catch (e) { return null; }
++ }
++ function linkCard(c) {
++ if (!c || typeof c !== 'object' || typeof c.title !== 'string' || !c.title) return null;
++ var url = httpsHref(c.url), action = c.action && httpsHref(c.action.href);
++ var box = el('div', 'oc-card'); box.dataset.kind = typeof c.kind === 'string' ? c.kind : 'link';
++ var img = typeof c.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(c.image) ? httpsHref(c.image) : null;
++ if (img) {
++ var pic = el('a', 'oc-card-pic'); pic.href = url || img; pic.target = '_blank'; pic.rel = 'noopener nofollow';
++ var picture = el('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
++ pic.appendChild(picture); box.appendChild(pic);
++ }
++ var body = el('div', 'oc-card-body');
++ if (url) {
++ var title = el('a', 'oc-card-title', c.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow';
++ body.appendChild(title);
++ } else body.appendChild(el('span', 'oc-card-title', c.title));
++ if (typeof c.line === 'string' && c.line) body.appendChild(el('p', 'oc-card-line', c.line));
++ if (typeof c.note === 'string' && c.note) body.appendChild(el('p', 'oc-card-note', c.note));
++ if (action && c.action && typeof c.action.label === 'string' && c.action.label) {
++ var go = el('a', 'oc-card-go', c.action.label); go.href = action; go.target = '_blank'; go.rel = 'noopener nofollow';
++ body.appendChild(go);
++ }
++ box.appendChild(body);
++ return box;
++ }
// OpenVibe.Pics direct links in a person's reply, or a signed-in visitor's, show as pictures (only that host, at most four).
var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;
function picsOf(text) {
@@@ -50,6 +79,7 @@
var said = vp ? bare(m.body) : m.body;
if (said) reply.appendChild(el('div', 'ovt-answer ib-text', said));
if (vp) reply.appendChild(vp);
++ var vc = linkCard(m.card); if (vc) reply.appendChild(vc);
bot.appendChild(reply); li.appendChild(bot);
li.dataset.author = 'visitor';
} else {
@@@ -63,6 +93,7 @@
bubble.appendChild(el('span', 'ib-text', pics ? bare(m.body) : m.body));
if (pics) bubble.appendChild(pics);
}
++ var ac = linkCard(m.card); if (ac) bubble.appendChild(ac);
you.appendChild(bubble); li.appendChild(you);
li.dataset.author = ai ? 'ai' : 'agent';
}
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 9092964..5654c2a 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -33,6 +33,7 @@ const ttsEngine = require('./tts-engine');
const soundboard = require('./soundboard-service');
const audioQueue = require('./audio-queue');
const dm = require('./dm');
++const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const presence = require('../presence');
const vipBadges = require('../vip/badges');
@@@ -1156,6 +1157,11 @@ class ChatServer {
} catch { /* no badge */ }
}
++ // A known OpenVibe link gets its card resolved now, with a second's budget: the line never waits on another
++ // site. A card that arrives later is filled into the saved row (server/chat/link-cards.js).
++ const { card, pending } = await linkCards.forSend(text);
++ if (card) chatMsg.card = card;
++
// Save to database. The save also records the first chat in the resolved channel
// (channel_user_id, or the stream owner), so a channel-only line counts too.
let firstChat = false;
@@@ -1172,8 +1178,10 @@ class ChatServer {
reply_to_id: replyToId,
auto_delete_at: autoDeleteAt,
metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,
++ card,
});
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
++ if (pending && result.lastInsertRowid) linkCards.fillLater('chat_messages', Number(result.lastInsertRowid), pending);
firstChat = !!result.first_chat;
} catch (err) {
// Not saved means not sent: a line everyone saw but history, moderation and replays never
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
iindex e868a5a..9e202dc 100644
---- a/server/chat/dm.js
++++ b/server/chat/dm.js
@@@ -19,6 +19,7 @@
const db = require('../db/database');
const outbox = require('../events/service-outbox');
const networkBlocks = require('./network-blocks');
++const linkCards = require('./link-cards');
// ── Schema & Migrations ──────────────────────────────────────
@@@ -165,10 +166,12 @@ async function sendMessage(conversationId, senderId, text) {
if (!text || !text.trim()) return null;
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const senderSubject = await db.subjectFor(senderId);
-- return await db.tx(async () => {
++ // A known OpenVibe link's card is resolved when the message is sent, with a second's budget (link-cards.js).
++ const { card, pending } = await linkCards.forSend(trimmed);
++ const row = await db.tx(async () => {
const result = await db.run(
-- `INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)`,
-- [conversationId, senderId, trimmed, senderSubject]
++ `INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id, card) VALUES (?, ?, ?, ?, ?)`,
++ [conversationId, senderId, trimmed, senderSubject, linkCards.toJson(card)]
);
// Touch conversation updated_at
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
@@@ -191,15 +194,21 @@ async function sendMessage(conversationId, senderId, text) {
});
return await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
});
++ if (row) {
++ row.card = card;
++ if (pending) linkCards.fillLater('dm_messages', row.id, pending);
++ }
++ return row;
}
/**
* Get messages in a conversation (paginated, newest first).
*/
async function getMessages(conversationId, limit = 50, before = null, after = null) {
++ let rows;
if (after) {
// Fetch messages newer than `after` id (for live polling)
-- return await db.all(`
++ rows = await db.all(`
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
@@@ -207,9 +216,8 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
ORDER BY m.created_at ASC
LIMIT ?
`, [conversationId, after, limit]);
-- }
-- if (before) {
-- return await db.all(`
++ } else if (before) {
++ rows = await db.all(`
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
@@@ -217,15 +225,17 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
ORDER BY m.created_at DESC
LIMIT ?
`, [conversationId, before, limit]);
++ } else {
++ rows = await db.all(`
++ SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_messages m
++ JOIN ctx_users u ON u.id = m.sender_id
++ WHERE m.conversation_id = ?
++ ORDER BY m.created_at DESC
++ LIMIT ?
++ `, [conversationId, limit]);
}
-- return await db.all(`
-- SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
-- FROM dm_messages m
-- JOIN ctx_users u ON u.id = m.sender_id
-- WHERE m.conversation_id = ?
-- ORDER BY m.created_at DESC
-- LIMIT ?
-- `, [conversationId, limit]);
++ return await linkCards.hydrate(rows, 'dm_messages');
}
/**
ddiff --git a/server/chat/history-store.js b/server/chat/history-store.js
iindex a77fd44..4921761 100644
---- a/server/chat/history-store.js
++++ b/server/chat/history-store.js
@@@ -19,6 +19,7 @@
* `readDelta` and calling `invalidate(room)` from the write path — nothing in the routes changes.
*/
const db = require('../db/database');
++const linkCards = require('./link-cards');
const PAGE_MEMO_MS = 2000;
const MAX_LIMIT = 500;
@@@ -127,7 +128,9 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
rows = await readPage(r, { limit: lim, before, channelUsername });
if (key) memoSet(key, rows);
}
-- const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
++ // Copies: the page memo holds the rows, and a refreshed card must not change what the next reader sees.
++ const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
++ await linkCards.hydrate(messages, 'chat_messages');
return { messages, latest_id: latestIdOf(rows) };
}
@@@ -143,7 +146,8 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
let rows = await readDelta(r, { afterId: after, limit: lim, channelUsername });
const complete = rows.length <= lim;
if (!complete) rows = rows.slice(0, lim);
-- const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
++ const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows.map((x) => ({ ...x }));
++ await linkCards.hydrate(messages, 'chat_messages');
return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };
}
ddiff --git a/server/chat/link-cards.js b/server/chat/link-cards.js
nnew file mode 100644
iindex 0000000..92b795d
---- /dev/null
++++ b/server/chat/link-cards.js
@@@ -0,0 +1,325 @@
++'use strict';
++/**
++ * Known OpenVibe links in a message get a card with the one move that link is for (plan HO, one account across every
++ * site: a pasted video, party invite, shared voice or skill shows what it is and one button).
++ *
++ * openvibe.video /watch?v=, /v/<n>, /c/<n> MediaHub /api/v1/video/watchable → a card: "Watch together"
++ * openvibe.party /p/<slug>?k=… Chat's own party (the link's secret must match) → "Join the party"
++ * openvibe.actor /voices/<id> GET /voices/<id>.json → "Borrow this agent"
++ * openvibe.actor /skills/<slug> GET /skills/<slug>.json → "Install", unless the scan failed
++ *
++ * Only exact hosts and path shapes are known, and the pasted URL is never fetched: it is a query parameter for
++ * Video's own resolver (https://openvibe.video/api/v1/video/watchable?url=), a database key for a party, and a path
++ * under Actor's fixed origin for the other two. A 404 (a private video, a stopped voice, no such skill), a party
++ * without its k=, an unknown host or an unreachable service is no card. Every request refuses redirects, gives up
++ * after 2.5 s, and is cached a minute per URL; a failed lookup caches its nothing for that minute too.
++ *
++ * One card per message: the first *known* link. The card's text is data: escaped where HTML is drawn (server/web/ui.js
++ * and public/web/chat.js), cut to size here, and a picture is only ever https on openvibe.media. The card is stored on
++ * the row as JSON when the message is sent.
++ *
++ * Sending never waits on another site: forSend() gives the resolver a second. A card ready inside it is stored with
++ * the message; a slower one is filled into the row afterwards (fillLater), so later reads have it and the sender's
++ * line goes out at once. A read refreshes a stored card once it is older than a day, at most SIX per page (the rest
++ * keep their stored card until a later read), and writes the refreshed card back (or null when the video, voice or
++ * skill is gone). A network error keeps the stored card: readJson() throws for that, and returns null for a 404.
++ */
++const db = require('../db/database');
++
++const CACHE_MS = 60_000;
++const FRESH_MS = 86_400_000;
++const SEND_BUDGET_MS = 1_000;
++const REFRESH_PER_READ = 6;
++const TABLES = new Set(['chat_messages', 'room_messages', 'dm_messages', 'inbox_messages']);
++const MAX_TITLE = 120;
++const MAX_LINE = 200;
++const MAX_NOTE = 160;
++const KINDS = ['video', 'party', 'voice', 'skill'];
++
++// A test names its own stand-in; a test that does not never reaches the real site (the same rule as party-pages.js).
++const 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 videoBase = () => baseOf(process.env.CHAT_LINK_VIDEO_URL, 'https://openvibe.video');
++const actorBase = () => baseOf(process.env.CHAT_LINK_ACTOR_URL, 'https://openvibe.actor');
++const partyBase = () => String(process.env.CHAT_PARTY_URL || 'https://openvibe.party').replace(/\/+$/, '');
++
++const cache = new Map(); // key → { at, value }
++
++/** One lookup, cached a minute. A throw (a network error) leaves the cache alone, so the next read tries again. */
++async function cached(key, fn) {
++ const hit = cache.get(key);
++ if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
++ const value = await fn();
++ if (cache.size > 500) cache.clear();
++ cache.set(key, { at: Date.now(), value });
++ return value;
++}
++
++/**
++ * A JSON read from a fixed origin and path: no redirects, 2.5 s. A 404 or another non-200 is null (nothing is
++ * there); a network error or the timeout throws, which is what keeps a stored card on a read.
++ */
++async function readJson(base, path) {
++ if (!base) throw new Error('link-cards: no origin for this service');
++ return await cached(`${base}${path}`, async () => {
++ const r = await fetch(`${base}${path}`, {
++ headers: { accept: 'application/json' },
++ redirect: 'error',
++ signal: AbortSignal.timeout(2500),
++ });
++ return r.ok ? await r.json() : null;
++ });
++}
++
++/** Plain card text: control characters gone, whitespace collapsed, cut to size (by character, so an emoji survives). */
++function textOf(value, max) {
++ const text = String(value == null ? '' : value).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
++ return Array.from(text).slice(0, max).join('');
++}
++
++/** A card's picture: https on openvibe.media, nothing else (a title cannot point the browser elsewhere). */
++function imageOf(value) {
++ if (typeof value !== 'string') return null;
++ try {
++ const u = new URL(value);
++ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
++ return u.hostname.toLowerCase() === 'openvibe.media' ? u.href : null;
++ } catch { return null; }
++}
++
++const duration = (sec) => {
++ const n = Number(sec);
++ if (!(n > 0)) return '';
++ const h = Math.floor(n / 3600), m = Math.floor((n % 3600) / 60), x = Math.floor(n % 60);
++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`;
++};
++
++/** A URL on one of Video's watch-page shapes: /watch?v=<11 chars>, /v/<n> or /c/<n> (the recording and clip pages). */
++function videoLink(u) {
++ if (u.hostname.toLowerCase().replace(/^www\./, '') !== 'openvibe.video') return null;
++ if (u.pathname === '/watch') {
++ return /^[A-Za-z0-9_-]{11}$/.test(String(u.searchParams.get('v') || '')) ? { kind: 'video', page: u.href } : null;
++ }
++ if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { kind: 'video', page: u.href };
++ return null;
++}
++
++/** A party's invite: /p/<slug>, with its secret in k=. Without k= there is no card (the page would 404 anyway). */
++function partyLink(u) {
++ if (u.hostname.toLowerCase().replace(/^www\./, '') !== 'openvibe.party') return null;
++ const m = /^\/p\/([A-Za-z0-9][A-Za-z0-9-]{0,63})$/.exec(u.pathname);
++ const key = String(u.searchParams.get('k') || '');
++ if (!m || !key || key.length > 200) return null;
++ return { kind: 'party', page: u.href, slug: m[1], key };
++}
++
++/** Actor's public pages: a shared voice (/voices/shv_…) and a skill (/skills/<slug>). */
++function actorLink(u) {
++ if (u.hostname.toLowerCase().replace(/^www\./, '') !== 'openvibe.actor') return null;
++ const voice = /^\/voices\/(shv_[0-9A-HJKMNP-TV-Z]{26})$/.exec(u.pathname);
++ if (voice) return { kind: 'voice', page: u.href, id: voice[1] };
++ const skill = /^\/skills\/([a-z0-9][a-z0-9-]{0,63})$/.exec(u.pathname);
++ if (skill) return { kind: 'skill', page: u.href, slug: skill[1] };
++ return null;
++}
++
++function known(raw) {
++ let u;
++ try { u = new URL(String(raw || '')); } catch { return null; }
++ if (u.protocol !== 'https:' || u.username || u.password || u.port) return null;
++ return videoLink(u) || partyLink(u) || actorLink(u);
++}
++
++/** The message's first *known* link, if it has one. Trailing punctuation a sentence leaves on a URL is dropped. */
++function firstLink(text) {
++ const urls = String(text || '').match(/https?:\/\/[^\s<>"']+/g) || [];
++ for (const raw of urls) {
++ if (raw.length > 2048) continue;
++ const link = known(raw.replace(/[.,;:!?]+$/, '').replace(/\)+$/, ''));
++ if (link) return link;
++ }
++ return null;
++}
++
++const SCAN_WORDS = {
++ passed: 'The scan found nothing to worry about.',
++ flagged: 'The scan found something to read before you install this.',
++ failed: 'The scan found something that stops this skill from being installed.',
++ pending: 'This skill has not been scanned yet.',
++};
++
++async function videoCard(link) {
++ const base = videoBase();
++ if (!base) return null;
++ const item = await readJson(base, `/api/v1/video/watchable?url=${encodeURIComponent(link.page)}`);
++ if (!item || typeof item !== 'object') return null;
++ const title = textOf(item.title, MAX_TITLE);
++ if (!title) return null;
++ const line = [duration(item.duration_s), textOf(item.channel, 60)].filter(Boolean).join(' \u00b7 ');
++ return {
++ kind: 'video',
++ url: link.page,
++ title,
++ line: line || 'A video on OpenVibe.Video',
++ image: imageOf(item.thumbnail_url),
++ action: { label: 'Watch together', href: `${partyBase()}/new?media=${encodeURIComponent(link.page)}` },
++ };
++}
++
++async function partyCard(link) {
++ // Chat's own row, not an outside call: no minute cache, so an ended party drops its card on the next read.
++ const parties = require('../rooms/parties');
++ const row = await parties.bySlug(link.slug);
++ const party = parties.partyOf(row);
++ if (!row || !party || party.ended_at || !parties.keyMatches(party, link.key)) return null;
++ const host = await db.get('SELECT username, display_name FROM ctx_users WHERE subject_id = ? LIMIT 1', [party.host_subject]);
++ const found = { title: row.name, host: host ? (host.display_name || host.username) : null };
++ return {
++ kind: 'party',
++ url: link.page,
++ title: textOf(found.title, MAX_TITLE) || 'A watch party',
++ line: found.host ? `Started by ${textOf(found.host, 60)}` : 'A watch party on OpenVibe',
++ image: null,
++ action: { label: 'Join the party', href: link.page },
++ };
++}
++
++async function voiceCard(link) {
++ const base = actorBase();
++ if (!base) return null;
++ const voice = await readJson(base, `/voices/${link.id}.json`);
++ if (!voice || typeof voice !== 'object') return null;
++ const title = textOf(voice.name, MAX_TITLE);
++ if (!title) return null;
++ return {
++ kind: 'voice',
++ url: link.page,
++ title,
++ line: textOf(voice.bio, MAX_LINE) || 'A shared agent on OpenVibe.Actor',
++ image: null,
++ action: { label: 'Borrow this agent', href: link.page },
++ };
++}
++
++async function skillCard(link) {
++ const base = actorBase();
++ if (!base) return null;
++ const listing = await readJson(base, `/skills/${link.slug}.json`);
++ const skill = listing && listing.skill;
++ if (!skill || typeof skill !== 'object') return null;
++ const title = textOf(skill.name, MAX_TITLE);
++ if (!title) return null;
++ const state = String((listing.scan && listing.scan.state) || '');
++ return {
++ kind: 'skill',
++ url: link.page,
++ title,
++ line: textOf(skill.description, MAX_LINE) || 'A skill for OpenVibe.Actor',
++ note: textOf(SCAN_WORDS[state] || SCAN_WORDS.pending, MAX_NOTE),
++ image: null,
++ // A failed scan never installs: no button. A flagged one installs on Actor's page after the person reads it.
++ action: state === 'failed' ? null : { label: 'Install', href: link.page },
++ };
++}
++
++/**
++ * The card for a message body: null unless it carries one of the known kinds, stamped with when it was resolved.
++ * A service that cannot answer throws (a party that is gone is null, not a throw).
++ */
++async function resolve(text) {
++ const link = firstLink(text);
++ if (!link) return null;
++ const card = link.kind === 'video' ? await videoCard(link)
++ : link.kind === 'party' ? await partyCard(link)
++ : link.kind === 'voice' ? await voiceCard(link)
++ : await skillCard(link);
++ if (!card || !KINDS.includes(card.kind) || !card.title) return null;
++ return { ...card, at: Date.now() };
++}
++
++/**
++ * The card to store at send time, within a second: { card, pending }. A card ready inside the budget comes back as
++ * `card`; a slower resolver keeps running as `pending`, and fillLater() writes what it finds. A service error is no
++ * card (the message still goes).
++ */
++async function forSend(text, budgetMs = SEND_BUDGET_MS) {
++ const job = resolve(text).catch(() => null);
++ const late = Symbol('late');
++ let timer;
++ const timeout = new Promise((done) => { timer = setTimeout(() => done(late), budgetMs); if (timer.unref) timer.unref(); });
++ let card;
++ try { card = await Promise.race([job, timeout]); } finally { clearTimeout(timer); }
++ return card === late ? { card: null, pending: job } : { card, pending: null };
++}
++
++/** Write a card that arrived after its message went out into that row. Never awaited by the request. */
++function fillLater(table, id, pending) {
++ if (!TABLES.has(table) || !id || !pending) return;
++ pending.then((card) => {
++ if (card) return db.run(`UPDATE ${table} SET card = ? WHERE id = ?`, [toJson(card), id]);
++ return null;
++ }).catch(() => {});
++}
++
++/** The stored card as an object. Anything else (null, bad JSON, an old shape) is no card. */
++function parse(value) {
++ if (!value) return null;
++ let card = value;
++ if (typeof card === 'string') {
++ try { card = JSON.parse(card); } catch { return null; }
++ }
++ if (!card || typeof card !== 'object' || Array.isArray(card)) return null;
++ return KINDS.includes(card.kind) && typeof card.title === 'string' && card.title ? card : null;
++}
++
++/**
++ * The card to show for one row: the stored card while it is fresh; stale, the link resolved again and written back.
++ * A network error keeps the stored card; a 404, a stopped voice, a failed skill or an ended party drops it (null).
++ */
++async function refresh(row, stored, table) {
++ let fresh;
++ try { fresh = await resolve(row.message != null ? row.message : row.body); }
++ catch { return stored; }
++ const id = row.id;
++ if (fresh) {
++ if (id != null) await db.run(`UPDATE ${table} SET card = ? WHERE id = ?`, [toJson(fresh), id]).catch(() => {});
++ return fresh;
++ }
++ if (id != null) await db.run(`UPDATE ${table} SET card = NULL WHERE id = ?`, [id]).catch(() => {});
++ return null;
++}
++
++/**
++ * Fill `row.card` on message rows from one of the four tables (message: chat/room/dm; body: inbox). One call starts
++ * at most REFRESH_PER_READ stale lookups; the others keep their stored card until a later read. The rows are the
++ * caller's copies (history-store's page memo must not be changed here).
++ */
++async function hydrate(rows, table) {
++ if (!Array.isArray(rows)) return rows;
++ if (!TABLES.has(table)) throw new TypeError(`link-cards: hydrate needs one of ${[...TABLES].join(', ')}`);
++ let budget = REFRESH_PER_READ;
++ await Promise.all(rows.map(async (row) => {
++ if (!row) return;
++ const card = parse(row.card);
++ if (!card) { row.card = null; return; }
++ if (Date.now() - (Number(card.at) || 0) < FRESH_MS) { row.card = card; return; }
++ if (budget <= 0) { row.card = card; return; }
++ budget--;
++ row.card = await refresh(row, card, table);
++ }));
++ return rows;
++}
++
++/** The JSON stored on the row at send time (null for no card). */
++const toJson = (card) => (card ? JSON.stringify(card) : null);
++
++module.exports = {
++ resolve, forSend, fillLater, parse, hydrate, toJson, firstLink, known, imageOf,
++ SEND_BUDGET_MS, REFRESH_PER_READ, TABLES, MAX_TITLE, MAX_LINE, MAX_NOTE, FRESH_MS,
++};
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex c203ea0..a3d5beb 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -18,6 +18,7 @@ const ctx = require('../live-context');
const { optionalAuth, requireAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const historyStore = require('./history-store');
++const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const { limits } = require('../net/actor-limits');
const { positiveInt } = require('./route-params');
@@@ -354,6 +355,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
if (tagProfile) chatMsg.tag = tagProfile;
} catch { /* non-critical */ }
++ // A known OpenVibe link gets its card resolved now, with a second's budget; a slower one fills the row later.
++ const { card, pending } = await linkCards.forSend(filtered);
++ if (card) chatMsg.card = card;
++
// Save to database
let savedId = null;
try {
@@@ -367,8 +372,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
is_global: true,
reply_to_id: replyToId,
auto_delete_at: autoDeleteAt,
++ card,
});
savedId = result.lastInsertRowid;
++ if (pending && savedId) linkCards.fillLater('chat_messages', Number(savedId), pending);
} catch { /* non-critical */ }
// Attach message ID and reply context to broadcast
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 6329552..a6e325a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -177,13 +177,15 @@ function firstChatKey({ message_type, source_platform, user_id, anon_id, usernam
return anon_id ? `anon:${anon_id}` : null;
}
--async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, first_chat_key }) {
++async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, card, first_chat_key }) {
// channel_user_id = the broadcaster's user id — set for all channel/stream
// messages so a streamer's chat history survives across sessions AND offline
// periods (independent of the live-session stream row's lifetime).
let chanUid = channel_user_id || null;
if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }
const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
++ // The link card resolved when the message was sent (server/chat/link-cards.js), as JSON text.
++ const cardStr = card == null ? null : (typeof card === 'string' ? card : JSON.stringify(card));
const subject = await subjectFor(user_id);
// Every save path records the chatter's first chat in the resolved channel here, so a
// channel-only line (a PowerChat or offline room with no stream) counts too. Only chat lines
@@@ -191,9 +193,9 @@ async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, u
const chatterKey = firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key });
return await transaction(async () => {
const res = await run(
-- `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
++ `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id, card)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject, cardStr]
);
res.first_chat = !!(chatterKey && chanUid) && await recordFirstChat(chatterKey, chanUid);
await _outbox().emit({
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex 89e2ea1..bf44367 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -20,6 +20,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const config = require('../config');
const wordFilter = require('../chat/word-filter');
++const linkCards = require('../chat/link-cards');
class InboxError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
const fail = (status, code, message) => { throw new InboxError(status, code, message); };
@@@ -205,11 +206,15 @@ const convShape = (c) => c && ({
created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,
});
// account: a visitor's message written signed in to OpenVibe, whose OpenVibe.Pics links show as pictures (anyone else's stay links).
--const msgShape = (m) => m && ({ id: Number(m.id), conversation_id: m.conversation_id, author: m.author, author_name: m.author_name || null, body: m.body, created_at: m.created_at, ...(m.author === 'visitor' && m.author_user_id != null ? { account: true } : {}) });
++const msgShape = (m) => m && ({ id: Number(m.id), conversation_id: m.conversation_id, author: m.author, author_name: m.author_name || null, body: m.body, card: linkCards.parse(m.card), created_at: m.created_at, ...(m.author === 'visitor' && m.author_user_id != null ? { account: true } : {}) });
async function addMessage(conv, { author, body, userId = null, name = null }, deps = {}) {
-- const row = await db.get(`INSERT INTO inbox_messages (conversation_id, author, author_user_id, author_name, body) VALUES (?, ?, ?, ?, ?) RETURNING *`,
-- [conv.id, author, userId, name, body]);
++ // A known OpenVibe link's card is resolved when the message is sent, with a second's budget; a system line never
++ // carries one. A slower card is filled into the row afterwards.
++ const { card, pending } = author === 'system' ? { card: null, pending: null } : await linkCards.forSend(body);
++ const row = await db.get(`INSERT INTO inbox_messages (conversation_id, author, author_user_id, author_name, body, card) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
++ [conv.id, author, userId, name, body, linkCards.toJson(card)]);
++ if (pending) linkCards.fillLater('inbox_messages', Number(row.id), pending);
await db.run(`UPDATE inbox_conversations SET last_message_at = ov_now()${author === 'visitor' ? ', agent_unread = agent_unread + 1' : ''} WHERE id = ?`, [conv.id]);
const message = msgShape(row);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_message', inbox: conv.inbox_id, conversation: conv.id, message });
@@@ -243,7 +248,9 @@ async function setStatusRow(conv, status, deps = {}, extra = {}) {
}
async function messages(conv, { after = 0, limit = 200 } = {}) {
-- return (await db.all('SELECT * FROM inbox_messages WHERE conversation_id = ? AND id > ? ORDER BY id LIMIT ?', [conv.id, Number(after) || 0, Math.min(500, Number(limit) || 200)])).map(msgShape);
++ const rows = await db.all('SELECT * FROM inbox_messages WHERE conversation_id = ? AND id > ? ORDER BY id LIMIT ?', [conv.id, Number(after) || 0, Math.min(500, Number(limit) || 200)]);
++ await linkCards.hydrate(rows, 'inbox_messages');
++ return rows.map(msgShape);
}
async function openConversationFor(inbox, token) {
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex c87bd8e..07bf332 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -47,6 +47,7 @@ const db = require('../db/database');
const ctx = require('../live-context');
const wordFilter = require('../chat/word-filter');
const permissions = require('../auth/permissions');
++const linkCards = require('../chat/link-cards');
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system', 'ice-servers']);
@@@ -297,14 +298,18 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
}
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
++ // The message's known OpenVibe link, with a second's budget: the card is stored on the row and arrives with the
++ // broadcast when it is ready; a slower one fills the row later.
++ const { card, pending } = await linkCards.forSend(message);
const id = await db.tx(async () => {
-- const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card) VALUES (?, ?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card)]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
});
const row = await messageById(id);
++ if (pending) linkCards.fillLater('room_messages', id, pending);
// The chat plays commands of an agent stream's room (Agent Streams S6): '!do', '!vote', and the
// moderators' '!skip', '!remove' and '!pin'. They are handled after the message is out, so a request is
// a message of the room and a thumbs up on it is a vote.
@@@ -335,22 +340,28 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results,
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE u.display_name END AS display_name,
m.agent_stream, u.profile_color, u.role AS user_role
FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id AND m.ai_member_id IS NULL
LEFT JOIN room_ai_members a ON a.id = m.ai_member_id`;
--async function messageById(id) { return await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }
++async function messageById(id) {
++ const row = await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null;
++ if (row) await linkCards.hydrate([row], 'room_messages');
++ return row;
++}
/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
async function history(room, { before = null, after = null, limit = 60 } = {}) {
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
-- if (after != null) return await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);
-- const rows = before != null
-- ? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
-- : await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
-- return rows.reverse();
++ const rows = after != null
++ ? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim])
++ : before != null
++ ? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
++ : await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
++ await linkCards.hydrate(rows, 'room_messages');
++ return after != null ? rows : rows.reverse();
}
async function markRead(room, user, lastId) {
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex f9b288a..0029a0a 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -23,6 +23,7 @@ const pictures = require('../rooms/pictures');
const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
++const ui = require('./ui');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
// A visitor's initial, in a circle, beside their row.
@@@ -214,13 +215,13 @@ ${list}
const name = current.visitor_name || 'Visitor';
return thread.turn({
who: { name, initials: name.slice(0, 1).toUpperCase() },
-- reply: `${shown ? `<div class="ovt-answer ib-text">${esc(shown)}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}`,
++ reply: `${shown ? `<div class="ovt-answer ib-text">${esc(shown)}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}${ui.linkCard(m.card)}`,
attrs: { 'data-id': m.id, 'data-author': 'visitor' },
});
}
const ai = m.author === 'ai';
return thread.turn({
-- you: { caption: ai ? 'Vibe (AI)' : (m.author_name || 'Agent'), html: ai ? `<div class="vb-md">${md.render(m.body)}</div>` : `<span class="ib-text">${esc(shown)}</span>${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}` },
++ you: { caption: ai ? 'Vibe (AI)' : (m.author_name || 'Agent'), html: `${ai ? `<div class="vb-md">${md.render(m.body)}</div>` : `<span class="ib-text">${esc(shown)}</span>${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}`}${ui.linkCard(m.card)}` },
reply: null, attrs: { 'data-id': m.id, 'data-author': ai ? 'ai' : 'agent' },
});
}
@@@ -361,7 +362,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
const msgs = await inboxes.messages(conv);
const who = (m) => (m.author === 'visitor' ? 'You' : m.author === 'ai' ? 'Vibe' : m.author === 'system' ? '' : (m.author_name || inbox.name));
const line = (m) => (m.author === 'system' ? `<li class="oc-muted sp-sys" data-id="${m.id}">${esc(m.body)}</li>`
-- : `<li class="sp-msg${m.author === 'visitor' ? ' sp-me' : ''}" data-id="${m.id}"><b>${esc(who(m))}</b> <span class="sp-text">${m.author === 'ai' ? md.render(m.body) : esc(m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body).replace(/\n/g, '<br>')}</span>${m.author === 'agent' || m.account ? pictures.pageHtml(m.body) : ''}</li>`);
++ : `<li class="sp-msg${m.author === 'visitor' ? ' sp-me' : ''}" data-id="${m.id}"><b>${esc(who(m))}</b> <span class="sp-text">${m.author === 'ai' ? md.render(m.body) : esc(m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body).replace(/\n/g, '<br>')}</span>${m.author === 'agent' || m.account ? pictures.pageHtml(m.body) : ''}${ui.linkCard(m.card)}</li>`);
await page(req, res, 200, {
actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow', footer: 'compact',
page: { view: 'support', inbox: inbox.id, conversation: conv.id, latest: msgs.length ? msgs[msgs.length - 1].id : 0 },
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 02598b2..06751bc 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -51,6 +51,7 @@ const ctx = require('../live-context');
const permissions = require('../auth/permissions');
const presence = require('../presence');
const db = require('../db/database');
++const ui = require('./ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const ANON = Object.freeze({ kind: 'anonymous' });
@@@ -122,12 +123,12 @@ function globalMessage(m) {
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;
++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
}
function dmMessage(m, me) {
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
-- return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span></li>`;
++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
}
// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are
// server/rooms/vibe.js): how it should be in a group.
@@@ -345,7 +346,6 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
// ── Direct messages ──
const dmApi = require('../chat/dm-routes');
-- const ui = require('./ui');
const { ICONS: IC } = require('./icons');
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null }) => {
@@@ -723,7 +723,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;
-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${del}</li>`;
++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${ui.linkCard(m.card)}${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex cb7faf0..423c809 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -88,8 +88,44 @@ function initials(name, presence = null, { large = false, id = '' } = {}) {
return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${esc(letters)}${presenceDot(presence)}</span>`;
}
++/** An https address a card may link to; anything else (javascript:, http:, a relative path) is no link. */
++function cardHref(value) {
++ try {
++ const u = new URL(String(value || ''));
++ return u.protocol === 'https:' ? u.href : null;
++ } catch { return null; }
++}
++/** A card's picture: only https on openvibe.media, as the resolver also enforces. */
++function cardImage(value) {
++ try {
++ const u = new URL(String(value || ''));
++ return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : null;
++ } catch { return null; }
++}
++/**
++ * A known OpenVibe link's card under its message (server/chat/link-cards.js): the picture when there is one, the
++ * title, one line, and the one move as a button. Every string is data: escaped here. Null is no markup.
++ */
++function linkCard(card) {
++ if (!card || typeof card !== 'object') return '';
++ const title = typeof card.title === 'string' ? card.title : '';
++ if (!title) return '';
++ const href = cardHref(card.url);
++ const action = card.action && typeof card.action.label === 'string' && card.action.label ? card.action : null;
++ const actionHref = action && cardHref(action.href);
++ const image = cardImage(card.image);
++ const line = typeof card.line === 'string' && card.line ? card.line : '';
++ const note = typeof card.note === 'string' && card.note ? card.note : '';
++ const head = href
++ ? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(title)}</a>`
++ : `<span class="oc-card-title">${esc(title)}</span>`;
++ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';
++ const go = actionHref ? `<a class="oc-card-go" href="${esc(actionHref)}" rel="noopener nofollow" target="_blank">${esc(action.label)}</a>` : '';
++ return `<div class="oc-card" data-kind="${esc(typeof card.kind === 'string' ? card.kind : 'link')}">${pic}<div class="oc-card-body">${head}${line ? `<p class="oc-card-line">${esc(line)}</p>` : ''}${note ? `<p class="oc-card-note">${esc(note)}</p>` : ''}${go}</div></div>`;
++}
++
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
--module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark };
++module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard };
ddiff --git a/test/link-cards.test.js b/test/link-cards.test.js
nnew file mode 100644
iindex 0000000..b3017b5
---- /dev/null
++++ b/test/link-cards.test.js
@@@ -0,0 +1,333 @@
++'use strict';
++/**
++ * A known OpenVibe link in a message shows a card under it (server/chat/link-cards.js, plan HO):
++ *
++ * - openvibe.video's watch pages resolve through Video's own /api/v1/video/watchable (a private one is no card);
++ * - an openvibe.party invite shows its title and who started it, and its "Join the party", only with its k=;
++ * - an openvibe.actor shared voice (/voices/<id>.json) and skill (/skills/<slug>.json) show their name and one
++ * line, with Borrow / Install; a failed scan has no Install button;
++ * - an unknown host or path, or a known one with nothing behind it, is no card;
++ * - one card per message; the text is cut and escaped where HTML is drawn, and a picture is only openvibe.media;
++ * - the same URL is fetched once a minute, and the card is stored on the message (room, DM, global, inbox) and
++ * shown by the page and the JSON API.
++ *
++ * Video and Actor are local stand-ins here: nothing in this file touches the network.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('link cards');
++const SITE = 'https://openvibe.chat';
++const VOICE = 'shv_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++let h, ann, bob, videoServer, actorServer, videoHits = 0, actorHits = 0, conv, room, staleRoom, inbox, visitorToken, partyJoin, partySlug;
++
++const start = (handler) => new Promise((resolve) => {
++ const s = http.createServer(handler);
++ s.listen(0, '127.0.0.1', () => resolve(s));
++});
++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
++const page = async (p, u) => {
++ const r = await fetch(`${h.base}${p}`, { headers: u ? cookie(u) : {} });
++ return { status: r.status, text: await r.text() };
++};
++const dmSend = (u, text) => h.http('POST', `/api/dm/conversations/${conv}/messages`, { token: u.token, body: { message: text } });
++const globalSend = (u, text) => h.http('POST', '/api/chat/send', { token: u.token, body: { message: text } });
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++const staleCard = (link, title = 'Old film', kind = 'video') => JSON.stringify({
++ kind, url: link, title, line: 'An old line', image: null,
++ action: kind === 'party' ? { label: 'Join the party', href: link } : { label: 'Watch together', href: `https://openvibe.party/new?media=${encodeURIComponent(link)}` },
++ at: Date.now() - 2 * 86_400_000,
++});
++const insertRoomMessage = async (r, text, card, user = ann) => Number((await h.db.run(
++ 'INSERT INTO room_messages (room_id, user_id, subject_id, message, card) VALUES (?, ?, ?, ?, ?)',
++ [r.id, user.id, user.subject_id || null, text, card]
++)).lastInsertRowid);
++const roomMessages = (r, u = bob) => h.http('GET', `/api/chat/rooms/${r.slug}/messages?limit=200`, { token: u.token });
++const afterRows = (afterId, limit, u = bob) => h.http('GET', `/api/chat/rooms/${staleRoom.slug}/messages?after=${afterId}&limit=${limit}`, { token: u.token });
++const cardIn = (html) => /<div class="oc-card" data-kind="([a-z]+)">([\s\S]*?)<\/div><\/div>/.exec(html);
++
++t('boot', async () => {
++ videoServer = await start((req, res) => {
++ videoHits++;
++ const u = new URL(req.url, 'http://stand-in');
++ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ if (u.pathname !== '/api/v1/video/watchable') return send(404, { error: 'not found' });
++ const watched = String(u.searchParams.get('url') || '');
++ if (watched.includes('netfail0001')) { req.socket.destroy(); return; }
++ // A private item, and anything that is not a Video page: Video's own 404.
++ if (!/^https:\/\/openvibe\.video\//.test(watched) || /\/v\/77[0-9]$/.test(watched)) return send(404, { error: 'not watchable' });
++ const escaping = watched.includes('escaping001');
++ const answer = () => send(200, {
++ kind: 'upload',
++ title: escaping ? `<img src=x onerror=alert(1)>${'x'.repeat(200)}` : watched.includes('slow0012345') ? 'Slow film' : 'Friday film',
++ page_url: watched,
++ thumbnail_url: escaping ? 'https://evil.example/pic.png' : 'https://openvibe.media/vod/friday.jpg',
++ duration_s: 3725,
++ channel: 'ann',
++ });
++ // Two seconds, so a send that waited for it would visibly stall; the one-second budget does not.
++ if (watched.includes('slow0012345')) { setTimeout(answer, 2000); return; }
++ answer();
++ });
++ actorServer = await start((req, res) => {
++ actorHits++;
++ const u = new URL(req.url, 'http://stand-in');
++ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ if (u.pathname === `/voices/${VOICE}.json`) {
++ return send(200, { name: 'The Researcher', bio: 'Reads the sources before it answers, and says what it read.' });
++ }
++ if (u.pathname === '/skills/good-skill.json') {
++ return send(200, { skill: { slug: 'good-skill', name: 'Good skill', description: 'Sums a thread up in three lines.' }, scan: { state: 'passed' } });
++ }
++ if (u.pathname === '/skills/flagged-skill.json') {
++ return send(200, { skill: { slug: 'flagged-skill', name: 'Flagged skill', description: 'Reaches one site.' }, scan: { state: 'flagged' } });
++ }
++ if (u.pathname === '/skills/bad-skill.json') {
++ return send(200, { skill: { slug: 'bad-skill', name: 'Bad skill', description: 'Asks for more than it says.' }, scan: { state: 'failed' } });
++ }
++ return send(404, { error: 'No such voice.' });
++ });
++ h = await boot({ env: {
++ CHAT_WEB_URL: SITE,
++ CHAT_PARTY_URL: 'https://openvibe.party',
++ CHAT_LINK_VIDEO_URL: `http://127.0.0.1:${videoServer.address().port}`,
++ CHAT_LINK_ACTOR_URL: `http://127.0.0.1:${actorServer.address().port}`,
++ } });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [ann, bob]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++ const made = await h.http('POST', '/api/dm/conversations', { token: ann.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(made.status, 200, made.text);
++ conv = made.body.conversation.id;
++});
++
++t('a video watch page is a card with Watch together, and the stored card comes back on read', async () => {
++ const r = await dmSend(ann, 'Watch this https://openvibe.video/watch?v=dQw4w9WgXcQ tonight');
++ assert.strictEqual(r.status, 200, r.text);
++ const card = r.body.message.card;
++ assert.strictEqual(card.kind, 'video');
++ assert.strictEqual(card.title, 'Friday film');
++ assert.strictEqual(card.image, 'https://openvibe.media/vod/friday.jpg');
++ assert.match(card.line, /1:02:05/);
++ assert.match(card.line, /ann/);
++ assert.strictEqual(card.action.label, 'Watch together');
++ assert.strictEqual(card.action.href, 'https://openvibe.party/new?media=' + encodeURIComponent('https://openvibe.video/watch?v=dQw4w9WgXcQ'));
++
++ const read = await h.http('GET', `/api/dm/conversations/${conv}/messages`, { token: bob.token });
++ const again = read.body.messages.find((m) => m.id === r.body.message.id);
++ assert.deepStrictEqual(again.card, card, 'the card is stored on the message and read back');
++ const html = await page(`/messages/${conv}`, bob);
++ assert.ok(html.text.includes('Watch together'), 'the conversation page draws the move');
++ assert.match(html.text, /<div class="oc-card" data-kind="video">/);
++ assert.ok(html.text.includes('oc-card-pic'), 'the picture');
++});
++
++t('a private video and a page Video does not vouch for are no card', async () => {
++ const priv = await dmSend(bob, 'https://openvibe.video/v/777');
++ assert.strictEqual(priv.status, 200, priv.text);
++ assert.strictEqual(priv.body.message.card, null);
++ const notv = await dmSend(bob, 'https://openvibe.video/not-a-watch-page');
++ assert.strictEqual(notv.body.message.card, null);
++});
++
++t('a party invite shows its title and who started it, only with its k=', async () => {
++ const made = await h.http('POST', '/api/chat/parties', { token: ann.token, body: {
++ media: { media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', role: 'video' }, title: 'Friday film night',
++ } });
++ assert.strictEqual(made.status, 201, made.text);
++ const join = made.body.party.join_url;
++ partyJoin = join;
++ partySlug = made.body.party.id;
++ const withKey = await dmSend(ann, `Come watch ${join}`);
++ assert.strictEqual(withKey.body.message.card.kind, 'party');
++ assert.strictEqual(withKey.body.message.card.title, 'Friday film night');
++ assert.strictEqual(withKey.body.message.card.line, 'Started by Ann');
++ assert.strictEqual(withKey.body.message.card.action.label, 'Join the party');
++ assert.strictEqual(withKey.body.message.card.action.href, join);
++ assert.ok(!/members|chat/i.test(withKey.body.message.card.line), 'a card never names the members or the chat');
++
++ const noKey = await dmSend(bob, `Come watch ${join.split('?')[0]}`);
++ assert.strictEqual(noKey.body.message.card, null, 'a party link without k= is no card');
++ const wrongKey = await dmSend(bob, `Come watch ${join.replace(/k=.*/, 'k=nope')}`);
++ assert.strictEqual(wrongKey.body.message.card, null, 'a wrong k= is no card either');
++});
++
++t('a shared voice is a card with Borrow this agent; a stopped one is no card', async () => {
++ const r = await dmSend(ann, `Borrow https://openvibe.actor/voices/${VOICE} for this`);
++ assert.strictEqual(r.body.message.card.kind, 'voice');
++ assert.strictEqual(r.body.message.card.title, 'The Researcher');
++ assert.match(r.body.message.card.line, /Reads the sources/);
++ assert.strictEqual(r.body.message.card.action.label, 'Borrow this agent');
++ assert.strictEqual(r.body.message.card.action.href, `https://openvibe.actor/voices/${VOICE}`);
++
++ const gone = await dmSend(bob, `Borrow https://openvibe.actor/voices/shv_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4 for this`);
++ assert.strictEqual(gone.body.message.card, null);
++});
++
++t('a skill is a card with its scan state in words; a failed scan has no Install button', async () => {
++ const passed = await dmSend(ann, 'https://openvibe.actor/skills/good-skill');
++ assert.strictEqual(passed.body.message.card.kind, 'skill');
++ assert.strictEqual(passed.body.message.card.title, 'Good skill');
++ assert.match(passed.body.message.card.note, /^The scan found nothing/);
++ assert.strictEqual(passed.body.message.card.action.label, 'Install');
++
++ const flagged = await dmSend(bob, 'https://openvibe.actor/skills/flagged-skill');
++ assert.match(flagged.body.message.card.note, /read before you install/);
++ assert.strictEqual(flagged.body.message.card.action.label, 'Install', 'Actor asks the person to read it first');
++
++ const failed = await dmSend(bob, 'https://openvibe.actor/skills/bad-skill');
++ assert.match(failed.body.message.card.note, /stops this skill/);
++ assert.strictEqual(failed.body.message.card.action, null, 'a failed scan shows no button');
++ const html = await page(`/messages/${conv}`, ann);
++ assert.ok(html.text.includes('The scan found something that stops this skill from being installed.'));
++
++ const missing = await dmSend(bob, 'https://openvibe.actor/skills/no-such-skill');
++ assert.strictEqual(missing.body.message.card, null);
++});
++
++t('an unknown link is no card, and only the first known link gets one', async () => {
++ const unknown = await dmSend(bob, 'https://example.com/watch?v=dQw4w9WgXcQ');
++ assert.strictEqual(unknown.body.message.card, null);
++ const two = await dmSend(ann, 'https://openvibe.video/v/123 then https://openvibe.video/v/456');
++ assert.strictEqual(two.body.message.card.kind, 'video');
++ assert.strictEqual(two.body.message.card.url, 'https://openvibe.video/v/123');
++});
++
++t('the card text is cut and escaped, and the picture only comes from openvibe.media', async () => {
++ const r = await dmSend(ann, 'https://openvibe.video/watch?v=escaping001');
++ const card = r.body.message.card;
++ assert.strictEqual(card.image, null, 'a picture on another host is dropped');
++ assert.ok(Array.from(card.title).length <= 120, 'the title is cut to size');
++ const html = await page(`/messages/${conv}`, bob);
++ assert.ok(html.text.includes('&lt;img src=x onerror=alert(1)&gt;'), 'the title is escaped as text');
++ assert.ok(!html.text.includes('<img src=x onerror='), 'never markup');
++ assert.ok(!html.text.includes('evil.example'), 'never the other host');
++});
++
++t('a room message stores and draws its card too', async () => {
++ const made = await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Link room' } });
++ assert.strictEqual(made.status, 201, made.text);
++ room = made.body.room;
++ const posted = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: ann.token, body: { message: 'https://openvibe.video/v/123' } });
++ assert.strictEqual(posted.status, 201, posted.text);
++ assert.strictEqual(posted.body.message.card.kind, 'video');
++ const list = await h.http('GET', `/api/chat/rooms/${room.slug}/messages`, { token: bob.token });
++ assert.strictEqual(list.body.messages.find((m) => m.id === posted.body.message.id).card.kind, 'video');
++ const html = await page(`/r/${room.slug}`, bob);
++ assert.match(html.text, /<div class="oc-card" data-kind="video">/);
++ assert.ok(html.text.includes('Watch together'));
++});
++
++t('global chat stores and draws its card, and the card is one per message', async () => {
++ const text = 'https://openvibe.video/v/456';
++ assert.strictEqual((await globalSend(ann, text)).status, 200);
++ const history = await h.http('GET', '/api/chat/global/history?limit=50', { token: bob.token });
++ const mine = history.body.messages.find((m) => m.message === text);
++ assert.ok(mine && mine.card && mine.card.kind === 'video', JSON.stringify(mine));
++ const html = await page('/', bob);
++ assert.match(html.text, /<div class="oc-card" data-kind="video">/);
++});
++
++t('a support inbox message stores its card and the inbox draws it', async () => {
++ const made = await h.http('POST', '/api/chat/inboxes', { token: ann.token, body: { name: 'Link support', ai_enabled: 0, handoff: 1 } });
++ assert.strictEqual(made.status, 201, made.text);
++ inbox = made.body.inbox;
++ const visit = await fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}/visit`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
++ const seen = await visit.json();
++ visitorToken = seen.token;
++ const post = await fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}/messages`, {
++ method: 'POST', headers: { 'content-type': 'application/json', 'x-visitor-token': visitorToken },
++ body: JSON.stringify({ message: 'I saw https://openvibe.video/v/123' }),
++ });
++ const answer = await post.json();
++ assert.strictEqual(post.status, 201, JSON.stringify(answer));
++ assert.strictEqual(answer.message.card.kind, 'video');
++ const html = await page(`/inbox/${inbox.id}?c=${answer.conversation.id}`, ann);
++ assert.match(html.text, /<div class="oc-card" data-kind="video">/);
++ assert.ok(html.text.includes('Watch together'));
++});
++
++t('the same URL is fetched once a minute', async () => {
++ const link = 'https://openvibe.video/watch?v=cachetest01';
++ const before = videoHits;
++ assert.strictEqual((await dmSend(ann, `one ${link}`)).body.message.card.kind, 'video');
++ assert.strictEqual((await dmSend(ann, `two ${link}`)).body.message.card.kind, 'video');
++ assert.strictEqual(videoHits - before, 1, `Video was asked ${videoHits - before} times`);
++});
++
++t('a send does not wait for a slow card, and the card lands on the row afterwards', async () => {
++ const started = Date.now();
++ const r = await dmSend(bob, 'https://openvibe.video/watch?v=slow0012345');
++ const took = Date.now() - started;
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(took < 1800, `the send took ${took} ms`);
++ assert.strictEqual(r.body.message.card, null, 'the line went out without waiting');
++ let card = null;
++ for (let i = 0; i < 60 && !card; i++) {
++ await sleep(100);
++ const row = await h.db.get('SELECT card FROM dm_messages WHERE id = ?', [r.body.message.id]);
++ card = row && row.card ? JSON.parse(row.card) : null;
++ }
++ assert.ok(card, 'the card is written to the row after it resolves');
++ assert.strictEqual(card.title, 'Slow film');
++ const read = await h.http('GET', `/api/dm/conversations/${conv}/messages`, { token: ann.token });
++ assert.strictEqual(read.body.messages.find((m) => m.id === r.body.message.id).card.title, 'Slow film');
++});
++
++t('a 100-message history refreshes at most six stale cards in one read', async () => {
++ const made = await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Stale cards' } });
++ assert.strictEqual(made.status, 201, made.text);
++ staleRoom = made.body.room;
++ for (let i = 0; i < 100; i++) {
++ const link = `https://openvibe.video/watch?v=bulk${String(i).padStart(7, '0')}`;
++ await insertRoomMessage(staleRoom, link, staleCard(link));
++ }
++ const before = videoHits;
++ const r = await roomMessages(staleRoom);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.messages.length, 100);
++ const wrote = r.body.messages.filter((m) => m.card && Number(m.card.at) > Date.now() - 60_000).length;
++ assert.strictEqual(wrote, 6, `six cards were refreshed (${wrote})`);
++ assert.strictEqual(videoHits - before, 6, `Video was asked ${videoHits - before} times`);
++});
++
++t('a refreshed card is written back, so the next read makes no request', async () => {
++ const link = 'https://openvibe.video/watch?v=writeback01';
++ const id = await insertRoomMessage(staleRoom, link, staleCard(link, 'Written back'));
++ const before = videoHits;
++ const first = await afterRows(id - 1, 1);
++ assert.strictEqual(first.body.messages[0].card.title, 'Friday film', 'the fresh answer replaces the old card');
++ assert.strictEqual(videoHits - before, 1);
++ const stored = await h.db.get('SELECT card FROM room_messages WHERE id = ?', [id]);
++ assert.ok(JSON.parse(stored.card).at > Date.now() - 60_000, 'the refreshed card is on the row');
++ const second = await afterRows(id - 1, 1);
++ assert.strictEqual(videoHits - before, 1, 'the second read did not ask Video again');
++ assert.strictEqual(second.body.messages[0].card.title, 'Friday film');
++});
++
++t('a network error keeps the stored card; a 404, and a party that ended, drop it', async () => {
++ const keptLink = 'https://openvibe.video/watch?v=netfail0001';
++ const kept = await insertRoomMessage(staleRoom, keptLink, staleCard(keptLink, 'Kept film'));
++ const goneLink = 'https://openvibe.video/v/778';
++ const gone = await insertRoomMessage(staleRoom, goneLink, staleCard(goneLink, 'Gone film'));
++
++ const r = await afterRows(kept - 1, 2);
++ assert.strictEqual(r.body.messages.find((m) => m.id === kept).card.title, 'Kept film', 'a network error keeps it');
++ assert.strictEqual(r.body.messages.find((m) => m.id === gone).card, null, 'a 404 drops it');
++ assert.strictEqual((await h.db.get('SELECT card FROM room_messages WHERE id = ?', [gone])).card, null, 'the drop is stored');
++
++ const ended = await h.http('POST', `/api/chat/rooms/${partySlug}/party/end`, { token: ann.token });
++ assert.strictEqual(ended.status, 200, ended.text);
++ const partyRow = await insertRoomMessage(staleRoom, partyJoin, staleCard(partyJoin, 'Party film', 'party'));
++ const after = await afterRows(partyRow - 1, 1);
++ assert.strictEqual(after.body.messages[0].card, null, 'an ended party drops it');
++ assert.strictEqual((await h.db.get('SELECT card FROM room_messages WHERE id = ?', [partyRow])).card, null, 'the drop is stored');
++});
++
++t.run(async () => {
++ if (h && h.close) await h.close();
++ if (videoServer) videoServer.close();
++ if (actorServer) actorServer.close();
++});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 1ec8035..f3770d5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",
'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',
++ 'server/chat/link-cards.js': 'OpenVibe.Video and OpenVibe.Actor (fixed https origins, or a test\'s CHAT_LINK_VIDEO_URL / CHAT_LINK_ACTOR_URL), fixed paths (a watchable lookup, /voices/<id>.json, /skills/<slug>.json); a pasted link is a query parameter or a path segment only, never fetched; redirects refused, 2.5 s, cached a minute',
'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
};
const root = path.join(__dirname, '..');