openvibers/openvibe.chat Commits
Known OpenVibe links in a message get a card with the one move they are for (#401)
Changes
nnew file mode 100644@@@ -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";@@@ -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; }@@@ -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;@@@ -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';}@@@ -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@@@ -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 charsconst 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_atawait 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_colorFROM dm_messages mJOIN ctx_users u ON u.id = m.sender_id@@@ -207,9 +216,8 @@ async function getMessages(conversationId, limit = 50, before = null, after = nuORDER BY m.created_at ASCLIMIT ?`, [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_colorFROM dm_messages mJOIN ctx_users u ON u.id = m.sender_id@@@ -217,15 +225,17 @@ async function getMessages(conversationId, limit = 50, before = null, after = nuORDER BY m.created_at DESCLIMIT ?`, [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');}/**@@@ -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) };}nnew file mode 100644@@@ -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,++};@@@ -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, hourif (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 databaselet savedId = null;try {@@@ -367,8 +372,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, houris_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@@@ -177,13 +177,15 @@ function firstChatKey({ message_type, source_platform, user_id, anon_id, usernamreturn 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, uconst 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({@@@ -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) {@@@ -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_roleFROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id AND m.ai_member_id IS NULLLEFT 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) {@@@ -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.shoconst 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 },@@@ -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);@@@ -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 };nnew file mode 100644@@@ -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('<img src=x onerror=alert(1)>'), '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();++});@@@ -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, '..');
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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";
@@ -239,6 +239,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -239,6 +239,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-move:hover { border-color: var(--oc-accent); }
.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. */
/* 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-pics { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
.oc-pic { display: block; line-height: 0; border-radius: 10px; }
.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; }
.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; }
@@ -91,6 +91,39 @@
@@ -91,6 +91,39 @@
}
}
if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
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.
// 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'] };
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; }
function badge(role) { var b = BADGES[role]; return b ? el('span', b[0], b[1]) : null; }
@@ -147,6 +180,7 @@
@@ -147,6 +180,7 @@
var b = badge(m.role); if (b) li.appendChild(b);
var b = badge(m.role); if (b) li.appendChild(b);
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ addCard(li, m);
return li;
return li;
}
}
function removeIds(ids) {
function removeIds(ids) {
@@ -228,6 +262,7 @@
@@ -228,6 +262,7 @@
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(el('span', 'oc-name', m.display_name || m.username));
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ addCard(li, m);
return li;
return li;
}
}
function startDm() {
function startDm() {
@@ -307,6 +342,7 @@
@@ -307,6 +342,7 @@
else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
li.appendChild(t);
li.appendChild(t);
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
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)) {
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 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;
var x = el('button'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message'); x.innerHTML = TRASH;
@@ -19,6 +19,35 @@
@@ -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 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 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); }
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).
// 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;
var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;
function picsOf(text) {
function picsOf(text) {
@@ -50,6 +79,7 @@
@@ -50,6 +79,7 @@
var said = vp ? bare(m.body) : m.body;
var said = vp ? bare(m.body) : m.body;
if (said) reply.appendChild(el('div', 'ovt-answer ib-text', said));
if (said) reply.appendChild(el('div', 'ovt-answer ib-text', said));
if (vp) reply.appendChild(vp);
if (vp) reply.appendChild(vp);
+ var vc = linkCard(m.card); if (vc) reply.appendChild(vc);
bot.appendChild(reply); li.appendChild(bot);
bot.appendChild(reply); li.appendChild(bot);
li.dataset.author = 'visitor';
li.dataset.author = 'visitor';
} else {
} else {
@@ -63,6 +93,7 @@
@@ -63,6 +93,7 @@
bubble.appendChild(el('span', 'ib-text', pics ? bare(m.body) : m.body));
bubble.appendChild(el('span', 'ib-text', pics ? bare(m.body) : m.body));
if (pics) bubble.appendChild(pics);
if (pics) bubble.appendChild(pics);
}
}
+ var ac = linkCard(m.card); if (ac) bubble.appendChild(ac);
you.appendChild(bubble); li.appendChild(you);
you.appendChild(bubble); li.appendChild(you);
li.dataset.author = ai ? 'ai' : 'agent';
li.dataset.author = ai ? 'ai' : 'agent';
}
}
@@ -33,6 +33,7 @@ const ttsEngine = require('./tts-engine');
@@ -33,6 +33,7 @@ const ttsEngine = require('./tts-engine');
const soundboard = require('./soundboard-service');
const soundboard = require('./soundboard-service');
const audioQueue = require('./audio-queue');
const audioQueue = require('./audio-queue');
const dm = require('./dm');
const dm = require('./dm');
+const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
const presence = require('../presence');
const presence = require('../presence');
const vipBadges = require('../vip/badges');
const vipBadges = require('../vip/badges');
@@ -1156,6 +1157,11 @@ class ChatServer {
@@ -1156,6 +1157,11 @@ class ChatServer {
} catch { /* no badge */ }
} 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
// 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.
// (channel_user_id, or the stream owner), so a channel-only line counts too.
let firstChat = false;
let firstChat = false;
@@ -1172,8 +1178,10 @@ class ChatServer {
@@ -1172,8 +1178,10 @@ class ChatServer {
reply_to_id: replyToId,
reply_to_id: replyToId,
auto_delete_at: autoDeleteAt,
auto_delete_at: autoDeleteAt,
metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,
metadata: chatMsg.vip_badge ? { vip_badge: chatMsg.vip_badge } : undefined,
+ card,
});
});
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
+ if (pending && result.lastInsertRowid) linkCards.fillLater('chat_messages', Number(result.lastInsertRowid), pending);
firstChat = !!result.first_chat;
firstChat = !!result.first_chat;
} catch (err) {
} catch (err) {
// Not saved means not sent: a line everyone saw but history, moderation and replays never
// Not saved means not sent: a line everyone saw but history, moderation and replays never
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
const db = require('../db/database');
const db = require('../db/database');
const outbox = require('../events/service-outbox');
const outbox = require('../events/service-outbox');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
+const linkCards = require('./link-cards');
// ── Schema & Migrations ──────────────────────────────────────
// ── Schema & Migrations ──────────────────────────────────────
@@ -165,10 +166,12 @@ async function sendMessage(conversationId, senderId, text) {
@@ -165,10 +166,12 @@ async function sendMessage(conversationId, senderId, text) {
if (!text || !text.trim()) return null;
if (!text || !text.trim()) return null;
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const senderSubject = await db.subjectFor(senderId);
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(
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
// Touch conversation updated_at
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
@@ -191,15 +194,21 @@ async function sendMessage(conversationId, senderId, text) {
@@ -191,15 +194,21 @@ async function sendMessage(conversationId, senderId, text) {
});
});
return await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
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).
* Get messages in a conversation (paginated, newest first).
*/
*/
async function getMessages(conversationId, limit = 50, before = null, after = null) {
async function getMessages(conversationId, limit = 50, before = null, after = null) {
+ let rows;
if (after) {
if (after) {
// Fetch messages newer than `after` id (for live polling)
// 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
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
JOIN ctx_users u ON u.id = m.sender_id
@@ -207,9 +216,8 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
@@ -207,9 +216,8 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
ORDER BY m.created_at ASC
ORDER BY m.created_at ASC
LIMIT ?
LIMIT ?
`, [conversationId, after, 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
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
JOIN ctx_users u ON u.id = m.sender_id
@@ -217,15 +225,17 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
@@ -217,15 +225,17 @@ async function getMessages(conversationId, limit = 50, before = null, after = nu
ORDER BY m.created_at DESC
ORDER BY m.created_at DESC
LIMIT ?
LIMIT ?
`, [conversationId, before, 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');
}
}
/**
/**
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
* `readDelta` and calling `invalidate(room)` from the write path — nothing in the routes changes.
* `readDelta` and calling `invalidate(room)` from the write path — nothing in the routes changes.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
+const linkCards = require('./link-cards');
const PAGE_MEMO_MS = 2000;
const PAGE_MEMO_MS = 2000;
const MAX_LIMIT = 500;
const MAX_LIMIT = 500;
@@ -127,7 +128,9 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
@@ -127,7 +128,9 @@ async function page(room, { limit, before, channelUsername, decorate } = {}) {
rows = await readPage(r, { limit: lim, before, channelUsername });
rows = await readPage(r, { limit: lim, before, channelUsername });
if (key) memoSet(key, rows);
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) };
return { messages, latest_id: latestIdOf(rows) };
}
}
@@ -143,7 +146,8 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
@@ -143,7 +146,8 @@ async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
let rows = await readDelta(r, { afterId: after, limit: lim, channelUsername });
let rows = await readDelta(r, { afterId: after, limit: lim, channelUsername });
const complete = rows.length <= lim;
const complete = rows.length <= lim;
if (!complete) rows = rows.slice(0, 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) };
return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,325 @@
@@ -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,
+};
@@ -18,6 +18,7 @@ const ctx = require('../live-context');
@@ -18,6 +18,7 @@ const ctx = require('../live-context');
const { optionalAuth, requireAuth } = require('../auth/auth');
const { optionalAuth, requireAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const historyStore = require('./history-store');
const historyStore = require('./history-store');
+const linkCards = require('./link-cards');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const { positiveInt } = require('./route-params');
const { positiveInt } = require('./route-params');
@@ -354,6 +355,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -354,6 +355,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
if (tagProfile) chatMsg.tag = tagProfile;
if (tagProfile) chatMsg.tag = tagProfile;
} catch { /* non-critical */ }
} 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
// Save to database
let savedId = null;
let savedId = null;
try {
try {
@@ -367,8 +372,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -367,8 +372,10 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
is_global: true,
is_global: true,
reply_to_id: replyToId,
reply_to_id: replyToId,
auto_delete_at: autoDeleteAt,
auto_delete_at: autoDeleteAt,
+ card,
});
});
savedId = result.lastInsertRowid;
savedId = result.lastInsertRowid;
+ if (pending && savedId) linkCards.fillLater('chat_messages', Number(savedId), pending);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
// Attach message ID and reply context to broadcast
// Attach message ID and reply context to broadcast
@@ -177,13 +177,15 @@ function firstChatKey({ message_type, source_platform, user_id, anon_id, usernam
@@ -177,13 +177,15 @@ function firstChatKey({ message_type, source_platform, user_id, anon_id, usernam
return anon_id ? `anon:${anon_id}` : null;
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
// 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
// messages so a streamer's chat history survives across sessions AND offline
// periods (independent of the live-session stream row's lifetime).
// periods (independent of the live-session stream row's lifetime).
let chanUid = channel_user_id || null;
let chanUid = channel_user_id || null;
if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }
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));
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);
const subject = await subjectFor(user_id);
// Every save path records the chatter's first chat in the resolved channel here, so a
// 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
// 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
@@ -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 });
const chatterKey = firstChatKey({ message_type, source_platform, user_id, anon_id, username, first_chat_key });
return await transaction(async () => {
return await transaction(async () => {
const res = await run(
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);
res.first_chat = !!(chatterKey && chanUid) && await recordFirstChat(chatterKey, chanUid);
await _outbox().emit({
await _outbox().emit({
@@ -20,6 +20,7 @@ const crypto = require('crypto');
@@ -20,6 +20,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const wordFilter = require('../chat/word-filter');
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; } }
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); };
const fail = (status, code, message) => { throw new InboxError(status, code, message); };
@@ -205,11 +206,15 @@ const convShape = (c) => c && ({
@@ -205,11 +206,15 @@ const convShape = (c) => c && ({
created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,
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).
// 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 = {}) {
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]);
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);
const message = msgShape(row);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_message', inbox: conv.inbox_id, conversation: conv.id, message });
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 = {}) {
@@ -243,7 +248,9 @@ async function setStatusRow(conv, status, deps = {}, extra = {}) {
}
}
async function messages(conv, { after = 0, limit = 200 } = {}) {
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) {
async function openConversationFor(inbox, token) {
@@ -47,6 +47,7 @@ const db = require('../db/database');
@@ -47,6 +47,7 @@ const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
const wordFilter = require('../chat/word-filter');
const wordFilter = require('../chat/word-filter');
const permissions = require('../auth/permissions');
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 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']);
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 =
@@ -297,14 +298,18 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
}
}
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
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 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 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 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);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
const row = await messageById(id);
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
// 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
// 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.
// 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 } = {
@@ -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.
// 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.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,
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
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
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`;
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`. */
/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
async function history(room, { before = null, after = null, limit = 60 } = {}) {
async function history(room, { before = null, after = null, limit = 60 } = {}) {
const lim = Math.min(200, Math.max(1, Number(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) {
async function markRead(room, user, lastId) {
@@ -23,6 +23,7 @@ const pictures = require('../rooms/pictures');
@@ -23,6 +23,7 @@ const pictures = require('../rooms/pictures');
const { embedOf, sendExport } = require('../inbox/routes');
const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
+const ui = require('./ui');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
// A visitor's initial, in a circle, beside their row.
// A visitor's initial, in a circle, beside their row.
@@ -214,13 +215,13 @@ ${list}
@@ -214,13 +215,13 @@ ${list}
const name = current.visitor_name || 'Visitor';
const name = current.visitor_name || 'Visitor';
return thread.turn({
return thread.turn({
who: { name, initials: name.slice(0, 1).toUpperCase() },
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' },
attrs: { 'data-id': m.id, 'data-author': 'visitor' },
});
});
}
}
const ai = m.author === 'ai';
const ai = m.author === 'ai';
return thread.turn({
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' },
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
@@ -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 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 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>`
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, {
await page(req, res, 200, {
actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow', footer: 'compact',
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 },
page: { view: 'support', inbox: inbox.id, conversation: conv.id, latest: msgs.length ? msgs[msgs.length - 1].id : 0 },
@@ -51,6 +51,7 @@ const ctx = require('../live-context');
@@ -51,6 +51,7 @@ const ctx = require('../live-context');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const presence = require('../presence');
const presence = require('../presence');
const db = require('../db/database');
const db = require('../db/database');
+const ui = require('./ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
@@ -122,12 +123,12 @@ function globalMessage(m) {
@@ -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 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 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>` : '';
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) {
function dmMessage(m, me) {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
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
// 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.
// 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
@@ -345,7 +346,6 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
// ── Direct messages ──
// ── Direct messages ──
const dmApi = require('../chat/dm-routes');
const dmApi = require('../chat/dm-routes');
- const ui = require('./ui');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
/** 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 }) => {
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
@@ -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)}`;
: `<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.
// 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>`;
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);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -88,8 +88,44 @@ function initials(name, presence = null, { large = false, id = '' } = {}) {
@@ -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>`;
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 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 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>`;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,333 @@
@@ -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('<img src=x onerror=alert(1)>'), '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();
+});
@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -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/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/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/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',
'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, '..');
const root = path.join(__dirname, '..');