openvibers/openvibe.chat Commits
A chat room on any website: widget.js data-room puts a public room in a bubble or an element; anyone reads live, people sign in from the frame with a one-time ticket (#147)
Changes
@@@ -473,6 +473,21 @@ for people and for companies, with "direct live support with an agent", "fully m- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies inother inboxes stay, signed "A former agent".++### A chat room on any website (the widget's room mode)++++The same tag puts a **public room** on any site (`server/web/room-embed-pages.js`, `public/web/room-embed.js`): as a++bubble, `<script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script>`, or inside an element of the++page, `data-target="#chat" data-height="520"`. The owner's room settings give both tags. Only public rooms embed (not++private ones, Vibe chats or a server's channels), and the frame may sit on any site. Anyone reads, live over the chat++socket (`join_room`); people sign in with OpenVibe to write, and the room's moderation and OpenVibe's global standard++apply because posting goes through `rooms.post`.++++Signing in from a frame: browsers give a frame on another site no openvibe.chat cookies, and the sign-in window loses++its link to the frame on openvibe.network (Cross-Origin-Opener-Policy). So the frame makes a random ticket, opens++`/embed/signin?ticket=…`, and asks `POST /embed/ticket` until the window has bound the ticket to the person's session++(two minutes, once). The token lives in the frame's own storage, which the browser partitions by the embedding site.++"Sign out" forgets it.++### Feeds: vibe coding, live (openvibe.chat/feeds)The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built@@@ -55,7 +55,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plainHTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat showsChat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).--3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-in++3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox++ whose people do not answer) **modes** of the widget; pictures, video and files in the embed (signed-invisitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),`openvibe-sdk/chat`, Events types (`chat.conversation.created`, `.handoff_requested`, `.resolved`), webhooks.@@@ -39,6 +39,7 @@ a { color: var(--accent); }.em-handoff button:focus-visible { outline: 2px solid var(--accent); }.em-composer { display: flex; gap: 8px; align-items: flex-end; margin: 8px 12px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--line); border-radius: 18px; background: var(--bg); }.em-composer:focus-within { border-color: var(--accent); }++.em-composer[hidden] { display: none; }.em-composer textarea { flex: 1; min-width: 0; border: 0; outline: 0; resize: none; background: transparent; color: var(--text); font: inherit; padding: 6px 0; max-height: 140px; }.em-composer button { all: unset; cursor: pointer; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); color: var(--on-accent, #fff); flex: none; font-weight: 800; }.em-composer button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }@@@ -53,3 +54,16 @@ a { color: var(--accent); }.em-rate-more { display: flex; gap: 6px; width: min(320px, 100%); }.em-rate-more input { flex: 1; min-width: 0; font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--text); }.em-rate-more button { all: unset; cursor: pointer; padding: 6px 12px; border-radius: 10px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; }++/* A chat room in a frame (public/web/room-embed.js): a compact list, a name and the line. */++.re-thread { gap: 2px; padding: 10px 12px; }++.re-msg { padding: 3px 2px; line-height: 1.45; overflow-wrap: anywhere; }++.re-msg b { font-weight: 700; margin-right: 2px; }++.re-ai b { color: var(--accent); }++.re-badge { display: inline-block; font-size: .65rem; font-weight: 700; padding: 0 5px; margin-left: 4px; border-radius: 6px; background: var(--panel); color: var(--muted); vertical-align: 1px; }++.re-dock { padding: 6px 12px 4px; }++.re-dock:empty { display: none; }++.re-signin { all: unset; box-sizing: border-box; cursor: pointer; display: block; width: 100%; text-align: center; padding: 10px 14px; border-radius: 14px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; }++.re-signin:hover { filter: brightness(1.08); }++.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.re-signin[disabled] { opacity: .65; cursor: default; }++.em-head a.em-x { color: inherit; text-decoration: none; }nnew file mode 100644@@@ -0,0 +1,140 @@++/* A chat room in a frame on any website (server/web/room-embed-pages.js /embed/room/:slug). Anyone reads; the room's++ messages arrive over the chat socket (join_room). To write, a person signs in through a window on openvibe.chat:++ this frame makes a random ticket, opens /embed/signin?ticket=…, and asks /embed/ticket until the window has bound it++ to their session; the token it gets back is kept in this frame's own storage (partitioned by the site embedding++ it) and sent with join_room. Everything a person wrote is set with textContent; links are made with DOM nodes. */++(function () {++ 'use strict';++ var root = document.getElementById('re');++ if (!root) return;++ var slug = root.getAttribute('data-room');++ var KEY = 'ov-embed-token';++ var thread = document.getElementById('re-thread');++ var form = document.getElementById('re-form');++ var input = document.getElementById('re-input');++ var dock = document.getElementById('re-dock');++ var signinBtn = document.getElementById('re-signin');++ var whoEl = document.getElementById('re-who');++ var errEl = root.querySelector('.em-error');++ var token = store(function (s) { return s.getItem(KEY); }) || null;++ var ws = null, delay = 1000, latest = 0, can = {}, role = null, polling = null;++++ function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not framed */ } }++ function bottom() { thread.scrollTop = thread.scrollHeight; }++ function error(text) { errEl.textContent = text || ''; errEl.hidden = !text; }++ function nearBottom() { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120; }++++ // Text with its http(s) links as links, built from nodes.++ function textNodes(parent, text) {++ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;++ while ((m = re.exec(text))) {++ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));++ var url = m[0].replace(/[.,!?)]+$/, ''); var a = el('a', null, url); a.href = url; a.target = '_blank'; a.rel = 'noopener nofollow ugc';++ parent.appendChild(a); last = m.index + url.length; re.lastIndex = last;++ }++ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));++ }++ function render(m) {++ if (!m || thread.querySelector('[data-id="' + m.id + '"]')) return;++ var empty = document.getElementById('re-empty'); if (empty) empty.remove();++ var stick = nearBottom();++ var li = el('li', 're-msg' + (m.ai_member_id != null ? ' re-ai' : '')); li.dataset.id = String(m.id);++ li.appendChild(el('b', null, m.display_name || m.username || 'someone'));++ if (m.ai_member_id != null) li.appendChild(el('span', 're-badge', 'AI'));++ li.appendChild(document.createTextNode(' '));++ var t = el('span', 're-text'); textNodes(t, String(m.message || '')); li.appendChild(t);++ thread.appendChild(li);++ latest = Math.max(latest, Number(m.id) || 0);++ if (stick) bottom();++ if (m.ai_member_id == null) toParent({ type: 'message', author: 'room' });++ }++ // The server's first page: links, and where the newest is.++ Array.prototype.forEach.call(thread.querySelectorAll('.re-msg'), function (li) {++ latest = Math.max(latest, Number(li.dataset.id) || 0);++ var t = li.querySelector('.re-text'); if (t) { var raw = t.textContent; t.textContent = ''; textNodes(t, raw); }++ });++++ function dockState() {++ form.hidden = !(token && can.post);++ dock.textContent = '';++ if (!token) { dock.appendChild(signinBtn); signinBtn.disabled = !!polling; signinBtn.textContent = polling ? 'Finish signing in in the window…' : 'Sign in with OpenVibe to chat'; }++ else if (!can.post && !role) { var j = el('button', 're-signin', 'Join this room to chat'); j.type = 'button'; j.addEventListener('click', join); dock.appendChild(j); }++ else if (!can.post) dock.appendChild(el('p', 'em-system', 'You can read this room but not write in it.'));++ whoEl.textContent = '';++ if (token) {++ whoEl.appendChild(document.createTextNode(' · '));++ var out = el('a', null, 'Sign out'); out.href = '#'; out.addEventListener('click', function (e) { e.preventDefault(); signOut(); });++ whoEl.appendChild(out);++ }++ }++++ function connect() {++ if (!window.WebSocket) return;++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: slug, token: token || undefined })); catchUp(); };++ ws.onmessage = function (ev) {++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }++ if (m.type === 'room_joined' && m.room === slug) { can = m.can || {}; role = m.role || null; dockState(); }++ else if (m.type === 'room_message' && m.room === slug) render(m.message);++ else if (m.type === 'room_error') error(m.message || 'Something went wrong.');++ };++ ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };++ }++ function reconnect() { if (ws) { ws.onclose = null; try { ws.close(); } catch (e) { /* closed */ } } connect(); }++ function catchUp() {++ if (!latest) return;++ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/messages?after=' + latest, { credentials: 'omit' })++ .then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && d.messages) d.messages.forEach(render); }).catch(function () {});++ }++++ // Sign in through a window on openvibe.chat, then collect the token with the ticket.++ function newTicket() {++ var b = new Uint8Array(32); crypto.getRandomValues(b);++ return btoa(String.fromCharCode.apply(null, b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');++ }++ signinBtn.addEventListener('click', function () {++ var ticket = newTicket();++ var w = window.open('/embed/signin?ticket=' + encodeURIComponent(ticket), 'ovchat-signin', 'width=480,height=680');++ if (!w) { error('Allow pop-ups for this site to sign in, or open the room on OpenVibe.'); return; }++ error('');++ var started = Date.now();++ polling = setInterval(function () {++ if (Date.now() - started > 120000) { clearInterval(polling); polling = null; dockState(); return; }++ fetch('/embed/ticket', { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket: ticket }) })++ .then(function (r) { return r.json(); }).then(function (d) {++ if (!d || !d.token) return;++ clearInterval(polling); polling = null;++ token = d.token; store(function (s) { s.setItem(KEY, token); });++ dockState(); reconnect(); input.focus();++ }).catch(function () {});++ }, 1500);++ dockState();++ });++ function signOut() { token = null; can = {}; role = null; store(function (s) { s.removeItem(KEY); }); dockState(); reconnect(); }++ function join() {++ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/join', { method: 'POST', credentials: 'omit', headers: { Authorization: 'Bearer ' + token } })++ .then(function (r) { if (r.status === 401) { signOut(); return; } if (!r.ok) return r.json().then(function (d) { error((d && d.error) || 'That did not work.'); }); reconnect(); })++ .catch(function () { error('You seem to be offline.'); });++ }++++ function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 120) + 'px'; }++ input.addEventListener('input', grow);++ input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit')); } });++ form.addEventListener('submit', function (e) {++ e.preventDefault();++ var text = input.value.trim();++ if (!text || !ws || ws.readyState !== 1) return;++ error('');++ ws.send(JSON.stringify({ type: 'room_message', message: text }));++ input.value = ''; grow(); bottom();++ });++ var close = root.querySelector('[data-close]');++ if (close) close.addEventListener('click', function () { toParent({ type: 'close' }); });++ window.addEventListener('message', function (e) { if (e.data && e.data.ov === 'chat' && e.data.type === 'visible' && e.data.visible) bottom(); });++++ dockState();++ bottom();++ connect();++})();@@@ -1,7 +1,9 @@/* OpenVibe Chat on any website (docs/platform.md): one tag, served at https://openvibe.chat/widget.js.-- * <script src="https://openvibe.chat/widget.js" data-inbox="inb_…" async></script>++ * <script src="https://openvibe.chat/widget.js" data-inbox="inb_…" async></script> a support inbox++ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script> a public chat room* data-position="left" puts the bubble on the left; data-color="#ff7a1a" colours it before the inbox's own colour loads;-- * data-open="1" opens the panel at once.++ * data-open="1" opens the panel at once. data-target="#chat" (a room) puts the room in that element instead of a++ * bubble, data-height="480" tall.* It draws a bubble in a shadow root (the site's CSS cannot reach it, it cannot reach the site's) and, on the first open,* an iframe of openvibe.chat/embed/inbox/<id>: the conversation lives there, on openvibe.chat's own origin, so the site* never sees the visitor's token and openvibe.chat never sees the site's cookies. The page may drive it:@@@ -12,8 +14,26 @@var me = document.currentScript;if (!me || window.OpenVibeChat) return;var inbox = me.getAttribute('data-inbox') || '';-- if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(inbox)) { console.warn('OpenVibe Chat: data-inbox is not an inbox id'); return; }++ var room = String(me.getAttribute('data-room') || '').toLowerCase();++ var isRoom = !inbox && /^[a-z0-9][a-z0-9-]{1,48}$/.test(room);++ if (!isRoom && !/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(inbox)) { console.warn('OpenVibe Chat: data-inbox is not an inbox id (or data-room a room)'); return; }var base = new URL(me.src).origin;++ // A room in an element of the page: just the frame, no bubble.++ var target = isRoom && me.getAttribute('data-target');++ if (target) {++ var mountInline = function () {++ var box = document.querySelector(target);++ if (!box) { console.warn('OpenVibe Chat: no element matches ' + target); return; }++ var f = document.createElement('iframe');++ f.title = 'Chat'; f.loading = 'lazy'; f.allow = 'clipboard-write';++ f.src = base + '/embed/room/' + encodeURIComponent(room);++ f.style.cssText = 'display:block;width:100%;height:' + Math.min(1200, Math.max(240, parseInt(me.getAttribute('data-height'), 10) || 480)) + 'px;border:0;border-radius:14px;color-scheme:normal';++ box.appendChild(f);++ };++ window.OpenVibeChat = { open: function () {}, close: function () {}, toggle: function () {} };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountInline); else mountInline();++ return;++ }var left = me.getAttribute('data-position') === 'left';var color = /^#[0-9a-fA-F]{6}$/.test(me.getAttribute('data-color') || '') ? me.getAttribute('data-color') : '#3b82f6';@@@ -49,7 +69,8 @@frame = document.createElement('iframe');frame.title = 'Chat';frame.allow = 'clipboard-write';-- frame.src = base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500));++ frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1'++ : base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500));panel.appendChild(frame);}function setOpen(v) {@@@ -816,6 +816,11 @@ ${aiSection}`,await page(req, res, 200, {actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}++${can.manage && await require('./room-embed-pages').embeddable(room) ? `<h2>Put this room on your site</h2>++<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>++<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre>++<p>Or in a part of your page (an element with id="chat"):</p>++<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre>` : ''}${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings"><label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label><label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>@@@ -895,6 +900,7 @@ ${attachList}`,// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });++ require('./room-embed-pages').mountRoomEmbedPages(router, { needUser, config });require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * A chat room on any website (docs/platform.md, the widget's community room mode):++ *++ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script> a bubble++ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" data-target="#chat" async></script> inline++ *++ * GET /embed/room/:slug the room in a frame (public rooms only; frame-ancestors any site): its latest messages,++ * live over the chat socket (public/web/room-embed.js); anyone reads, people sign in to write++ * GET /embed/signin?ticket= the sign-in window the frame opens: OpenVibe's sign-in, then the ticket is bound to the++ * person's session for two minutes++ * POST /embed/ticket { ticket } → { token } once the window bound it (the frame asks every second or two)++ *++ * Why a ticket: a frame on another site gets no openvibe.chat cookies (browsers block or partition them), and the++ * sign-in window loses its link to the frame when it passes through openvibe.network (Cross-Origin-Opener-Policy++ * same-origin), so neither a cookie nor postMessage can carry the sign-in back. The frame makes a random ticket, the++ * window binds it after sign-in, and the frame collects the token once. Moderation is the room's and OpenVibe's++ * global standard: posting goes through rooms.post like everywhere else.++ */++const crypto = require('crypto');++const rateLimit = require('express-rate-limit');++const rooms = require('../rooms/rooms');++const { esc, asset } = require('./layout');++++const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;++const TICKET_MS = 2 * 60 * 1000;++const tickets = new Map(); // sha256(ticket) → { token, at }++++function sweep(now = Date.now()) { for (const [k, v] of tickets) if (now - v.at > TICKET_MS) tickets.delete(k); }++const hash = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');++const hhmm = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? '' : d.toISOString(); };++++/** May this room be put on other sites? Public, readable by anyone, not a Vibe chat or a server's channel. */++async function embeddable(room) {++ if (!room || room.visibility !== 'public' || Number(room.assistant) === 1 || room.server_id) return false;++ return !!(await rooms.access(room, null)).read;++}++++function messageHtml(m) {++ const ai = m.ai_member_id != null;++ return `<li class="re-msg${ai ? ' re-ai' : ''}" data-id="${Number(m.id) || 0}"><time datetime="${esc(hhmm(m.created_at))}"></time><b>${esc(m.display_name || m.username || 'someone')}</b>${ai ? '<span class="re-badge">AI</span>' : ''} <span class="re-text">${esc(m.message)}</span></li>`;++}++++function embedHtml(room, members, msgs, { bubble }) {++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(room.name)} · OpenVibe Chat</title>++<meta name="robots" content="noindex, nofollow">++<link rel="stylesheet" href="${asset('embed.css')}">++<script src="${asset('room-embed.js')}" defer></script>++</head>++<body style="--accent:#3b82f6">++<div class="em re" id="re" data-room="${esc(room.slug)}"${bubble ? ' data-bubble="1"' : ''}>++<header class="em-head"><span class="em-mark" aria-hidden="true">#</span>++<div><b>${esc(room.name)}</b><span>${members} ${members === 1 ? 'member' : 'members'}${room.topic ? ` · ${esc(room.topic)}` : ''}</span></div>++<a class="em-x" href="https://openvibe.chat/r/${esc(room.slug)}" target="_blank" rel="noopener" aria-label="Open the room on OpenVibe" title="Open on OpenVibe">↗</a>${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</button>' : ''}</header>++<ol class="em-thread re-thread" id="re-thread" aria-live="polite" aria-label="Messages">${msgs.map(messageHtml).join('') || '<li class="em-system" id="re-empty">No messages yet. Say hello.</li>'}</ol>++<p class="em-error" role="alert" hidden></p>++<form class="em-composer" id="re-form" hidden><label class="em-sr" for="re-input" hidden>Your message</label>++<textarea id="re-input" rows="1" maxlength="2000" placeholder="Message ${esc(room.name)}" aria-label="Your message"></textarea>++<button type="submit" aria-label="Send">↑</button></form>++<div class="re-dock" id="re-dock"><button type="button" class="re-signin" id="re-signin">Sign in with OpenVibe to chat</button></div>++<p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a><span id="re-who"></span></p>++</div>++<noscript><p style="padding:16px">This chat needs JavaScript.</p></noscript>++</body>++</html>`;++}++++function mountRoomEmbedPages(router, { needUser, config }) {++ const base = config.web.baseUrl;++ const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });++++ router.get('/embed/room/:slug', async (req, res) => {++ const room = await rooms.bySlug(req.params.slug);++ if (!(await embeddable(room))) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Room not found</title><p style="font-family:system-ui;padding:16px">This room is not available here.</p>');++ const msgs = await rooms.history(room, { limit: 50 });++ const members = (await rooms.publicRoom(room)).members;++ res.set('Content-Security-Policy', [++ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:",++ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, 'frame-ancestors *', "base-uri 'self'", "form-action 'self'", "object-src 'none'",++ ].join('; ')).set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html')++ .send(embedHtml(room, members, msgs, { bubble: req.query.bubble === '1' }));++ });++++ router.get('/embed/signin', async (req, res) => {++ const ticket = String(req.query.ticket || '');++ if (!TICKET_RE.test(ticket)) return res.status(400).type('text').send('This sign-in link is not complete. Close this window and try again.');++ const actor = await needUser(req, res); if (!actor) return;++ sweep();++ if (tickets.size > 10000) return res.status(503).type('text').send('Too many sign-ins at once. Try again in a minute.');++ tickets.set(hash(ticket), { token: actor.token, at: Date.now() });++ const nonce = crypto.randomBytes(16).toString('base64');++ res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; frame-ancestors 'none'`)++ .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(`<!DOCTYPE html>++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Signed in · OpenVibe Chat</title></head>++<body style="font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;text-align:center;padding:16px">++<div><p style="font-size:1.25rem;font-weight:700;margin:0 0 6px">You're signed in as ${esc(actor.user.display_name || actor.user.username)}.</p>++<p style="margin:0;color:#5b6577">Go back to the chat; this window closes by itself.</p></div>++<script nonce="${nonce}">setTimeout(function () { window.close(); }, 1200);</script>++</body></html>`);++ });++++ router.post('/embed/ticket', ticketLimit, (req, res) => {++ const ticket = String((req.body && req.body.ticket) || '');++ if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });++ sweep();++ const key = hash(ticket);++ const hit = tickets.get(key);++ if (!hit) return res.status(202).set('Cache-Control', 'no-store').json({ pending: true });++ tickets.delete(key); // once++ res.set('Cache-Control', 'no-store').json({ token: hit.token });++ });++}++++function _reset() { tickets.clear(); }++++module.exports = { mountRoomEmbedPages, embeddable, _reset };nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * A chat room on any website (server/web/room-embed-pages.js): only public rooms embed (not private ones, Vibe chats or++ * server channels); the frame may sit on any site; a person signs in through a window and the frame collects their++ * token once with a ticket; with it the socket joins the room as them and they post; anyone reads live; the owner's++ * room settings give the tag.++ */++const assert = require('assert');++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('room embed');++const SITE = 'https://openvibe.chat';++let h, ann, bob;++const page = (path, u, opts = {}) => fetch(`${h.base}${path}`, { redirect: 'manual', ...opts, headers: { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(opts.headers || {}) } })++ .then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));++const ticket = () => crypto.randomBytes(32).toString('base64url');++const collect = (tk) => fetch(`${h.base}/embed/ticket`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ticket: tk }) })++ .then(async (r) => ({ status: r.status, body: await r.json() }));++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ 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));++ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Pixel Fans', slug: 'pixel-fans' } })).status, 201);++ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Secret Club', slug: 'secret-club', visibility: 'private' } })).status, 201);++ await h.http('POST', '/api/chat/rooms/pixel-fans/messages', { token: ann.token, body: { message: 'Welcome! New lamps on https://pixellamp.example/new' } });++});++++t('only a public room embeds, on any site, with its latest messages', async () => {++ const r = await page('/embed/room/pixel-fans');++ assert.strictEqual(r.status, 200);++ assert.ok(/frame-ancestors \*/.test(r.headers.get('content-security-policy')));++ assert.ok(r.text.includes('data-room="pixel-fans"') && r.text.includes('Welcome! New lamps on https://pixellamp.example/new'));++ assert.ok(r.text.includes('Sign in with OpenVibe to chat') && !r.text.includes('data-close'), 'inline: no close button');++ assert.ok((await page('/embed/room/pixel-fans?bubble=1')).text.includes('data-close'), 'in a bubble it closes');++ assert.strictEqual((await page('/embed/room/secret-club')).status, 404, 'private rooms stay home');++ const vibe = (await h.http('POST', '/api/chat/vibe', { token: ann.token, body: { message: 'hi' } })).body;++ if (vibe && vibe.room) assert.strictEqual((await page(`/embed/room/${vibe.room.slug}`)).status, 404, 'a Vibe chat is private');++ assert.strictEqual((await page('/embed/room/no-such-room')).status, 404);++});++++t('signing in from the frame: a window binds the ticket, the frame collects the token once', async () => {++ const tk = ticket();++ assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } });++ const out = await page(`/embed/signin?ticket=${tk}`);++ assert.strictEqual(out.status, 303);++ assert.ok(out.headers.get('location').startsWith('/auth/login?next=%2Fembed%2Fsignin%3Fticket%3D'), 'sign in first, then back');++ const ok = await page(`/embed/signin?ticket=${tk}`, ann);++ assert.ok(ok.status === 200 && ok.text.includes('signed in as Ann') && /frame-ancestors 'none'/.test(ok.headers.get('content-security-policy')));++ assert.strictEqual(ok.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual(await collect(tk), { status: 200, body: { token: ann.token } });++ assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } }, 'once');++ assert.strictEqual((await collect('short')).status, 400);++ assert.strictEqual((await page('/embed/signin?ticket=bad', ann)).status, 400);++});++++t('with the token the socket joins as them: a newcomer joins, posts, and everyone reading sees it', async () => {++ const reader = await h.ws({});++ reader.sendJson({ type: 'join_room', room: 'pixel-fans' });++ const r = await reader.next((m) => m.type === 'room_joined');++ assert.strictEqual(r.can.post, false, 'anyone reads, nobody anonymous writes');++ const tk = ticket();++ await page(`/embed/signin?ticket=${tk}`, bob);++ const token = (await collect(tk)).body.token;++ const writer = await h.ws({});++ writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });++ const first = await writer.next((m) => m.type === 'room_joined');++ assert.deepStrictEqual([first.role, first.can.post], [null, false], 'not a member yet');++ assert.strictEqual((await h.http('POST', '/api/chat/rooms/pixel-fans/join', { token })).status, 200);++ writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });++ const joined = await writer.next((m) => m.type === 'room_joined' && m.can.post);++ assert.strictEqual(joined.role, 'member');++ writer.sendJson({ type: 'room_message', message: 'Hi from a fan site!' });++ const got = await reader.next((m) => m.type === 'room_message' && m.message.message === 'Hi from a fan site!');++ assert.strictEqual(got.message.username, 'bob');++ reader.close(); writer.close();++});++++t('the tag: widget.js knows rooms (bubble or inline), and the owner\'s settings give it', async () => {++ const js = await (await fetch(`${h.base}/widget.js`)).text();++ assert.ok(js.includes("'/embed/room/'") && js.includes('data-target'));++ const settings = await page('/r/pixel-fans/settings', ann);++ assert.ok(settings.text.includes('Put this room on your site') && settings.text.includes('data-room="pixel-fans"'));++ assert.ok(!(await page('/r/secret-club/settings', ann)).text.includes('Put this room on your site'), 'a private room has no tag');++});++++t.run(async () => { require('../server/web/room-embed-pages')._reset(); if (h && h.close) await h.close(); });
@@ -473,6 +473,21 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -473,6 +473,21 @@ for people and for companies, with "direct live support with an agent", "fully m
- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
other inboxes stay, signed "A former agent".
other inboxes stay, signed "A former agent".
+### A chat room on any website (the widget's room mode)
+
+The same tag puts a **public room** on any site (`server/web/room-embed-pages.js`, `public/web/room-embed.js`): as a
+bubble, `<script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script>`, or inside an element of the
+page, `data-target="#chat" data-height="520"`. The owner's room settings give both tags. Only public rooms embed (not
+private ones, Vibe chats or a server's channels), and the frame may sit on any site. Anyone reads, live over the chat
+socket (`join_room`); people sign in with OpenVibe to write, and the room's moderation and OpenVibe's global standard
+apply because posting goes through `rooms.post`.
+
+Signing in from a frame: browsers give a frame on another site no openvibe.chat cookies, and the sign-in window loses
+its link to the frame on openvibe.network (Cross-Origin-Opener-Policy). So the frame makes a random ticket, opens
+`/embed/signin?ticket=…`, and asks `POST /embed/ticket` until the window has bound the ticket to the person's session
+(two minutes, once). The token lives in the frame's own storage, which the browser partitions by the embedding site.
+"Sign out" forgets it.
+
### Feeds: vibe coding, live (openvibe.chat/feeds)
### Feeds: vibe coding, live (openvibe.chat/feeds)
The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built
The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built
@@ -55,7 +55,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
@@ -55,7 +55,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plain
room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plain
HTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat shows
HTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat shows
Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
-3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-in
+3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox
+ whose people do not answer) **modes** of the widget; pictures, video and files in the embed (signed-in
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
`openvibe-sdk/chat`, Events types (`chat.conversation.created`, `.handoff_requested`, `.resolved`), webhooks.
`openvibe-sdk/chat`, Events types (`chat.conversation.created`, `.handoff_requested`, `.resolved`), webhooks.
@@ -39,6 +39,7 @@ a { color: var(--accent); }
@@ -39,6 +39,7 @@ a { color: var(--accent); }
.em-handoff button:focus-visible { outline: 2px solid var(--accent); }
.em-handoff button:focus-visible { outline: 2px solid var(--accent); }
.em-composer { display: flex; gap: 8px; align-items: flex-end; margin: 8px 12px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--line); border-radius: 18px; background: var(--bg); }
.em-composer { display: flex; gap: 8px; align-items: flex-end; margin: 8px 12px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--line); border-radius: 18px; background: var(--bg); }
.em-composer:focus-within { border-color: var(--accent); }
.em-composer:focus-within { border-color: var(--accent); }
+.em-composer[hidden] { display: none; }
.em-composer textarea { flex: 1; min-width: 0; border: 0; outline: 0; resize: none; background: transparent; color: var(--text); font: inherit; padding: 6px 0; max-height: 140px; }
.em-composer textarea { flex: 1; min-width: 0; border: 0; outline: 0; resize: none; background: transparent; color: var(--text); font: inherit; padding: 6px 0; max-height: 140px; }
.em-composer button { all: unset; cursor: pointer; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); color: var(--on-accent, #fff); flex: none; font-weight: 800; }
.em-composer button { all: unset; cursor: pointer; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); color: var(--on-accent, #fff); flex: none; font-weight: 800; }
.em-composer button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.em-composer button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -53,3 +54,16 @@ a { color: var(--accent); }
@@ -53,3 +54,16 @@ a { color: var(--accent); }
.em-rate-more { display: flex; gap: 6px; width: min(320px, 100%); }
.em-rate-more { display: flex; gap: 6px; width: min(320px, 100%); }
.em-rate-more input { flex: 1; min-width: 0; font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--text); }
.em-rate-more input { flex: 1; min-width: 0; font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--text); }
.em-rate-more button { all: unset; cursor: pointer; padding: 6px 12px; border-radius: 10px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; }
.em-rate-more button { all: unset; cursor: pointer; padding: 6px 12px; border-radius: 10px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; }
+/* A chat room in a frame (public/web/room-embed.js): a compact list, a name and the line. */
+.re-thread { gap: 2px; padding: 10px 12px; }
+.re-msg { padding: 3px 2px; line-height: 1.45; overflow-wrap: anywhere; }
+.re-msg b { font-weight: 700; margin-right: 2px; }
+.re-ai b { color: var(--accent); }
+.re-badge { display: inline-block; font-size: .65rem; font-weight: 700; padding: 0 5px; margin-left: 4px; border-radius: 6px; background: var(--panel); color: var(--muted); vertical-align: 1px; }
+.re-dock { padding: 6px 12px 4px; }
+.re-dock:empty { display: none; }
+.re-signin { all: unset; box-sizing: border-box; cursor: pointer; display: block; width: 100%; text-align: center; padding: 10px 14px; border-radius: 14px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 600; }
+.re-signin:hover { filter: brightness(1.08); }
+.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.re-signin[disabled] { opacity: .65; cursor: default; }
+.em-head a.em-x { color: inherit; text-decoration: none; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+/* A chat room in a frame on any website (server/web/room-embed-pages.js /embed/room/:slug). Anyone reads; the room's
+ messages arrive over the chat socket (join_room). To write, a person signs in through a window on openvibe.chat:
+ this frame makes a random ticket, opens /embed/signin?ticket=…, and asks /embed/ticket until the window has bound it
+ to their session; the token it gets back is kept in this frame's own storage (partitioned by the site embedding
+ it) and sent with join_room. Everything a person wrote is set with textContent; links are made with DOM nodes. */
+(function () {
+ 'use strict';
+ var root = document.getElementById('re');
+ if (!root) return;
+ var slug = root.getAttribute('data-room');
+ var KEY = 'ov-embed-token';
+ var thread = document.getElementById('re-thread');
+ var form = document.getElementById('re-form');
+ var input = document.getElementById('re-input');
+ var dock = document.getElementById('re-dock');
+ var signinBtn = document.getElementById('re-signin');
+ var whoEl = document.getElementById('re-who');
+ var errEl = root.querySelector('.em-error');
+ var token = store(function (s) { return s.getItem(KEY); }) || null;
+ var ws = null, delay = 1000, latest = 0, can = {}, role = null, polling = null;
+
+ function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not framed */ } }
+ function bottom() { thread.scrollTop = thread.scrollHeight; }
+ function error(text) { errEl.textContent = text || ''; errEl.hidden = !text; }
+ function nearBottom() { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120; }
+
+ // Text with its http(s) links as links, built from nodes.
+ function textNodes(parent, text) {
+ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
+ while ((m = re.exec(text))) {
+ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
+ var url = m[0].replace(/[.,!?)]+$/, ''); var a = el('a', null, url); a.href = url; a.target = '_blank'; a.rel = 'noopener nofollow ugc';
+ parent.appendChild(a); last = m.index + url.length; re.lastIndex = last;
+ }
+ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
+ }
+ function render(m) {
+ if (!m || thread.querySelector('[data-id="' + m.id + '"]')) return;
+ var empty = document.getElementById('re-empty'); if (empty) empty.remove();
+ var stick = nearBottom();
+ var li = el('li', 're-msg' + (m.ai_member_id != null ? ' re-ai' : '')); li.dataset.id = String(m.id);
+ li.appendChild(el('b', null, m.display_name || m.username || 'someone'));
+ if (m.ai_member_id != null) li.appendChild(el('span', 're-badge', 'AI'));
+ li.appendChild(document.createTextNode(' '));
+ var t = el('span', 're-text'); textNodes(t, String(m.message || '')); li.appendChild(t);
+ thread.appendChild(li);
+ latest = Math.max(latest, Number(m.id) || 0);
+ if (stick) bottom();
+ if (m.ai_member_id == null) toParent({ type: 'message', author: 'room' });
+ }
+ // The server's first page: links, and where the newest is.
+ Array.prototype.forEach.call(thread.querySelectorAll('.re-msg'), function (li) {
+ latest = Math.max(latest, Number(li.dataset.id) || 0);
+ var t = li.querySelector('.re-text'); if (t) { var raw = t.textContent; t.textContent = ''; textNodes(t, raw); }
+ });
+
+ function dockState() {
+ form.hidden = !(token && can.post);
+ dock.textContent = '';
+ if (!token) { dock.appendChild(signinBtn); signinBtn.disabled = !!polling; signinBtn.textContent = polling ? 'Finish signing in in the window…' : 'Sign in with OpenVibe to chat'; }
+ else if (!can.post && !role) { var j = el('button', 're-signin', 'Join this room to chat'); j.type = 'button'; j.addEventListener('click', join); dock.appendChild(j); }
+ else if (!can.post) dock.appendChild(el('p', 'em-system', 'You can read this room but not write in it.'));
+ whoEl.textContent = '';
+ if (token) {
+ whoEl.appendChild(document.createTextNode(' · '));
+ var out = el('a', null, 'Sign out'); out.href = '#'; out.addEventListener('click', function (e) { e.preventDefault(); signOut(); });
+ whoEl.appendChild(out);
+ }
+ }
+
+ function connect() {
+ if (!window.WebSocket) return;
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
+ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: slug, token: token || undefined })); catchUp(); };
+ ws.onmessage = function (ev) {
+ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
+ if (m.type === 'room_joined' && m.room === slug) { can = m.can || {}; role = m.role || null; dockState(); }
+ else if (m.type === 'room_message' && m.room === slug) render(m.message);
+ else if (m.type === 'room_error') error(m.message || 'Something went wrong.');
+ };
+ ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
+ }
+ function reconnect() { if (ws) { ws.onclose = null; try { ws.close(); } catch (e) { /* closed */ } } connect(); }
+ function catchUp() {
+ if (!latest) return;
+ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/messages?after=' + latest, { credentials: 'omit' })
+ .then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { if (d && d.messages) d.messages.forEach(render); }).catch(function () {});
+ }
+
+ // Sign in through a window on openvibe.chat, then collect the token with the ticket.
+ function newTicket() {
+ var b = new Uint8Array(32); crypto.getRandomValues(b);
+ return btoa(String.fromCharCode.apply(null, b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ }
+ signinBtn.addEventListener('click', function () {
+ var ticket = newTicket();
+ var w = window.open('/embed/signin?ticket=' + encodeURIComponent(ticket), 'ovchat-signin', 'width=480,height=680');
+ if (!w) { error('Allow pop-ups for this site to sign in, or open the room on OpenVibe.'); return; }
+ error('');
+ var started = Date.now();
+ polling = setInterval(function () {
+ if (Date.now() - started > 120000) { clearInterval(polling); polling = null; dockState(); return; }
+ fetch('/embed/ticket', { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket: ticket }) })
+ .then(function (r) { return r.json(); }).then(function (d) {
+ if (!d || !d.token) return;
+ clearInterval(polling); polling = null;
+ token = d.token; store(function (s) { s.setItem(KEY, token); });
+ dockState(); reconnect(); input.focus();
+ }).catch(function () {});
+ }, 1500);
+ dockState();
+ });
+ function signOut() { token = null; can = {}; role = null; store(function (s) { s.removeItem(KEY); }); dockState(); reconnect(); }
+ function join() {
+ fetch('/api/chat/rooms/' + encodeURIComponent(slug) + '/join', { method: 'POST', credentials: 'omit', headers: { Authorization: 'Bearer ' + token } })
+ .then(function (r) { if (r.status === 401) { signOut(); return; } if (!r.ok) return r.json().then(function (d) { error((d && d.error) || 'That did not work.'); }); reconnect(); })
+ .catch(function () { error('You seem to be offline.'); });
+ }
+
+ function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 120) + 'px'; }
+ input.addEventListener('input', grow);
+ input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit')); } });
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var text = input.value.trim();
+ if (!text || !ws || ws.readyState !== 1) return;
+ error('');
+ ws.send(JSON.stringify({ type: 'room_message', message: text }));
+ input.value = ''; grow(); bottom();
+ });
+ var close = root.querySelector('[data-close]');
+ if (close) close.addEventListener('click', function () { toParent({ type: 'close' }); });
+ window.addEventListener('message', function (e) { if (e.data && e.data.ov === 'chat' && e.data.type === 'visible' && e.data.visible) bottom(); });
+
+ dockState();
+ bottom();
+ connect();
+})();
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
/* OpenVibe Chat on any website (docs/platform.md): one tag, served at https://openvibe.chat/widget.js.
/* OpenVibe Chat on any website (docs/platform.md): one tag, served at https://openvibe.chat/widget.js.
- * <script src="https://openvibe.chat/widget.js" data-inbox="inb_…" async></script>
+ * <script src="https://openvibe.chat/widget.js" data-inbox="inb_…" async></script> a support inbox
+ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script> a public chat room
* data-position="left" puts the bubble on the left; data-color="#ff7a1a" colours it before the inbox's own colour loads;
* data-position="left" puts the bubble on the left; data-color="#ff7a1a" colours it before the inbox's own colour loads;
- * data-open="1" opens the panel at once.
+ * data-open="1" opens the panel at once. data-target="#chat" (a room) puts the room in that element instead of a
+ * bubble, data-height="480" tall.
* It draws a bubble in a shadow root (the site's CSS cannot reach it, it cannot reach the site's) and, on the first open,
* It draws a bubble in a shadow root (the site's CSS cannot reach it, it cannot reach the site's) and, on the first open,
* an iframe of openvibe.chat/embed/inbox/<id>: the conversation lives there, on openvibe.chat's own origin, so the site
* an iframe of openvibe.chat/embed/inbox/<id>: the conversation lives there, on openvibe.chat's own origin, so the site
* never sees the visitor's token and openvibe.chat never sees the site's cookies. The page may drive it:
* never sees the visitor's token and openvibe.chat never sees the site's cookies. The page may drive it:
@@ -12,8 +14,26 @@
@@ -12,8 +14,26 @@
var me = document.currentScript;
var me = document.currentScript;
if (!me || window.OpenVibeChat) return;
if (!me || window.OpenVibeChat) return;
var inbox = me.getAttribute('data-inbox') || '';
var inbox = me.getAttribute('data-inbox') || '';
- if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(inbox)) { console.warn('OpenVibe Chat: data-inbox is not an inbox id'); return; }
+ var room = String(me.getAttribute('data-room') || '').toLowerCase();
+ var isRoom = !inbox && /^[a-z0-9][a-z0-9-]{1,48}$/.test(room);
+ if (!isRoom && !/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(inbox)) { console.warn('OpenVibe Chat: data-inbox is not an inbox id (or data-room a room)'); return; }
var base = new URL(me.src).origin;
var base = new URL(me.src).origin;
+ // A room in an element of the page: just the frame, no bubble.
+ var target = isRoom && me.getAttribute('data-target');
+ if (target) {
+ var mountInline = function () {
+ var box = document.querySelector(target);
+ if (!box) { console.warn('OpenVibe Chat: no element matches ' + target); return; }
+ var f = document.createElement('iframe');
+ f.title = 'Chat'; f.loading = 'lazy'; f.allow = 'clipboard-write';
+ f.src = base + '/embed/room/' + encodeURIComponent(room);
+ f.style.cssText = 'display:block;width:100%;height:' + Math.min(1200, Math.max(240, parseInt(me.getAttribute('data-height'), 10) || 480)) + 'px;border:0;border-radius:14px;color-scheme:normal';
+ box.appendChild(f);
+ };
+ window.OpenVibeChat = { open: function () {}, close: function () {}, toggle: function () {} };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountInline); else mountInline();
+ return;
+ }
var left = me.getAttribute('data-position') === 'left';
var left = me.getAttribute('data-position') === 'left';
var color = /^#[0-9a-fA-F]{6}$/.test(me.getAttribute('data-color') || '') ? me.getAttribute('data-color') : '#3b82f6';
var color = /^#[0-9a-fA-F]{6}$/.test(me.getAttribute('data-color') || '') ? me.getAttribute('data-color') : '#3b82f6';
@@ -49,7 +69,8 @@
@@ -49,7 +69,8 @@
frame = document.createElement('iframe');
frame = document.createElement('iframe');
frame.title = 'Chat';
frame.title = 'Chat';
frame.allow = 'clipboard-write';
frame.allow = 'clipboard-write';
- frame.src = base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500));
+ frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1'
+ : base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500));
panel.appendChild(frame);
panel.appendChild(frame);
}
}
function setOpen(v) {
function setOpen(v) {
@@ -816,6 +816,11 @@ ${aiSection}`,
@@ -816,6 +816,11 @@ ${aiSection}`,
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
+${can.manage && await require('./room-embed-pages').embeddable(room) ? `<h2>Put this room on your site</h2>
+<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
+<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" async></script>`)}</code></pre>
+<p>Or in a part of your page (an element with id="chat"):</p>
+<pre class="ib-snippet"><code>${esc(`<script src="${config.web.baseUrl}/widget.js" data-room="${room.slug}" data-target="#chat" data-height="520" async></script>`)}</code></pre>` : ''}
${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">
${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
@@ -895,6 +900,7 @@ ${attachList}`,
@@ -895,6 +900,7 @@ ${attachList}`,
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
+ require('./room-embed-pages').mountRoomEmbedPages(router, { needUser, config });
require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });
require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * A chat room on any website (docs/platform.md, the widget's community room mode):
+ *
+ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script> a bubble
+ * <script src="https://openvibe.chat/widget.js" data-room="<slug>" data-target="#chat" async></script> inline
+ *
+ * GET /embed/room/:slug the room in a frame (public rooms only; frame-ancestors any site): its latest messages,
+ * live over the chat socket (public/web/room-embed.js); anyone reads, people sign in to write
+ * GET /embed/signin?ticket= the sign-in window the frame opens: OpenVibe's sign-in, then the ticket is bound to the
+ * person's session for two minutes
+ * POST /embed/ticket { ticket } → { token } once the window bound it (the frame asks every second or two)
+ *
+ * Why a ticket: a frame on another site gets no openvibe.chat cookies (browsers block or partition them), and the
+ * sign-in window loses its link to the frame when it passes through openvibe.network (Cross-Origin-Opener-Policy
+ * same-origin), so neither a cookie nor postMessage can carry the sign-in back. The frame makes a random ticket, the
+ * window binds it after sign-in, and the frame collects the token once. Moderation is the room's and OpenVibe's
+ * global standard: posting goes through rooms.post like everywhere else.
+ */
+const crypto = require('crypto');
+const rateLimit = require('express-rate-limit');
+const rooms = require('../rooms/rooms');
+const { esc, asset } = require('./layout');
+
+const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
+const TICKET_MS = 2 * 60 * 1000;
+const tickets = new Map(); // sha256(ticket) → { token, at }
+
+function sweep(now = Date.now()) { for (const [k, v] of tickets) if (now - v.at > TICKET_MS) tickets.delete(k); }
+const hash = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
+const hhmm = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? '' : d.toISOString(); };
+
+/** May this room be put on other sites? Public, readable by anyone, not a Vibe chat or a server's channel. */
+async function embeddable(room) {
+ if (!room || room.visibility !== 'public' || Number(room.assistant) === 1 || room.server_id) return false;
+ return !!(await rooms.access(room, null)).read;
+}
+
+function messageHtml(m) {
+ const ai = m.ai_member_id != null;
+ return `<li class="re-msg${ai ? ' re-ai' : ''}" data-id="${Number(m.id) || 0}"><time datetime="${esc(hhmm(m.created_at))}"></time><b>${esc(m.display_name || m.username || 'someone')}</b>${ai ? '<span class="re-badge">AI</span>' : ''} <span class="re-text">${esc(m.message)}</span></li>`;
+}
+
+function embedHtml(room, members, msgs, { bubble }) {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(room.name)} · OpenVibe Chat</title>
+<meta name="robots" content="noindex, nofollow">
+<link rel="stylesheet" href="${asset('embed.css')}">
+<script src="${asset('room-embed.js')}" defer></script>
+</head>
+<body style="--accent:#3b82f6">
+<div class="em re" id="re" data-room="${esc(room.slug)}"${bubble ? ' data-bubble="1"' : ''}>
+<header class="em-head"><span class="em-mark" aria-hidden="true">#</span>
+<div><b>${esc(room.name)}</b><span>${members} ${members === 1 ? 'member' : 'members'}${room.topic ? ` · ${esc(room.topic)}` : ''}</span></div>
+<a class="em-x" href="https://openvibe.chat/r/${esc(room.slug)}" target="_blank" rel="noopener" aria-label="Open the room on OpenVibe" title="Open on OpenVibe">↗</a>${bubble ? '<button class="em-x" type="button" data-close aria-label="Close the chat">×</button>' : ''}</header>
+<ol class="em-thread re-thread" id="re-thread" aria-live="polite" aria-label="Messages">${msgs.map(messageHtml).join('') || '<li class="em-system" id="re-empty">No messages yet. Say hello.</li>'}</ol>
+<p class="em-error" role="alert" hidden></p>
+<form class="em-composer" id="re-form" hidden><label class="em-sr" for="re-input" hidden>Your message</label>
+<textarea id="re-input" rows="1" maxlength="2000" placeholder="Message ${esc(room.name)}" aria-label="Your message"></textarea>
+<button type="submit" aria-label="Send">↑</button></form>
+<div class="re-dock" id="re-dock"><button type="button" class="re-signin" id="re-signin">Sign in with OpenVibe to chat</button></div>
+<p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a><span id="re-who"></span></p>
+</div>
+<noscript><p style="padding:16px">This chat needs JavaScript.</p></noscript>
+</body>
+</html>`;
+}
+
+function mountRoomEmbedPages(router, { needUser, config }) {
+ const base = config.web.baseUrl;
+ const ticketLimit = rateLimit({ windowMs: 60_000, max: 90, standardHeaders: true, legacyHeaders: false });
+
+ router.get('/embed/room/:slug', async (req, res) => {
+ const room = await rooms.bySlug(req.params.slug);
+ if (!(await embeddable(room))) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Room not found</title><p style="font-family:system-ui;padding:16px">This room is not available here.</p>');
+ const msgs = await rooms.history(room, { limit: 50 });
+ const members = (await rooms.publicRoom(room)).members;
+ res.set('Content-Security-Policy', [
+ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:",
+ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, 'frame-ancestors *', "base-uri 'self'", "form-action 'self'", "object-src 'none'",
+ ].join('; ')).set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html')
+ .send(embedHtml(room, members, msgs, { bubble: req.query.bubble === '1' }));
+ });
+
+ router.get('/embed/signin', async (req, res) => {
+ const ticket = String(req.query.ticket || '');
+ if (!TICKET_RE.test(ticket)) return res.status(400).type('text').send('This sign-in link is not complete. Close this window and try again.');
+ const actor = await needUser(req, res); if (!actor) return;
+ sweep();
+ if (tickets.size > 10000) return res.status(503).type('text').send('Too many sign-ins at once. Try again in a minute.');
+ tickets.set(hash(ticket), { token: actor.token, at: Date.now() });
+ const nonce = crypto.randomBytes(16).toString('base64');
+ res.set('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; frame-ancestors 'none'`)
+ .set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(`<!DOCTYPE html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Signed in · OpenVibe Chat</title></head>
+<body style="font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;text-align:center;padding:16px">
+<div><p style="font-size:1.25rem;font-weight:700;margin:0 0 6px">You're signed in as ${esc(actor.user.display_name || actor.user.username)}.</p>
+<p style="margin:0;color:#5b6577">Go back to the chat; this window closes by itself.</p></div>
+<script nonce="${nonce}">setTimeout(function () { window.close(); }, 1200);</script>
+</body></html>`);
+ });
+
+ router.post('/embed/ticket', ticketLimit, (req, res) => {
+ const ticket = String((req.body && req.body.ticket) || '');
+ if (!TICKET_RE.test(ticket)) return res.status(400).json({ error: 'No such ticket', code: 'embed.ticket' });
+ sweep();
+ const key = hash(ticket);
+ const hit = tickets.get(key);
+ if (!hit) return res.status(202).set('Cache-Control', 'no-store').json({ pending: true });
+ tickets.delete(key); // once
+ res.set('Cache-Control', 'no-store').json({ token: hit.token });
+ });
+}
+
+function _reset() { tickets.clear(); }
+
+module.exports = { mountRoomEmbedPages, embeddable, _reset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * A chat room on any website (server/web/room-embed-pages.js): only public rooms embed (not private ones, Vibe chats or
+ * server channels); the frame may sit on any site; a person signs in through a window and the frame collects their
+ * token once with a ticket; with it the socket joins the room as them and they post; anyone reads live; the owner's
+ * room settings give the tag.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('room embed');
+const SITE = 'https://openvibe.chat';
+let h, ann, bob;
+const page = (path, u, opts = {}) => fetch(`${h.base}${path}`, { redirect: 'manual', ...opts, headers: { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(opts.headers || {}) } })
+ .then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));
+const ticket = () => crypto.randomBytes(32).toString('base64url');
+const collect = (tk) => fetch(`${h.base}/embed/ticket`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ticket: tk }) })
+ .then(async (r) => ({ status: r.status, body: await r.json() }));
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ 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));
+ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Pixel Fans', slug: 'pixel-fans' } })).status, 201);
+ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Secret Club', slug: 'secret-club', visibility: 'private' } })).status, 201);
+ await h.http('POST', '/api/chat/rooms/pixel-fans/messages', { token: ann.token, body: { message: 'Welcome! New lamps on https://pixellamp.example/new' } });
+});
+
+t('only a public room embeds, on any site, with its latest messages', async () => {
+ const r = await page('/embed/room/pixel-fans');
+ assert.strictEqual(r.status, 200);
+ assert.ok(/frame-ancestors \*/.test(r.headers.get('content-security-policy')));
+ assert.ok(r.text.includes('data-room="pixel-fans"') && r.text.includes('Welcome! New lamps on https://pixellamp.example/new'));
+ assert.ok(r.text.includes('Sign in with OpenVibe to chat') && !r.text.includes('data-close'), 'inline: no close button');
+ assert.ok((await page('/embed/room/pixel-fans?bubble=1')).text.includes('data-close'), 'in a bubble it closes');
+ assert.strictEqual((await page('/embed/room/secret-club')).status, 404, 'private rooms stay home');
+ const vibe = (await h.http('POST', '/api/chat/vibe', { token: ann.token, body: { message: 'hi' } })).body;
+ if (vibe && vibe.room) assert.strictEqual((await page(`/embed/room/${vibe.room.slug}`)).status, 404, 'a Vibe chat is private');
+ assert.strictEqual((await page('/embed/room/no-such-room')).status, 404);
+});
+
+t('signing in from the frame: a window binds the ticket, the frame collects the token once', async () => {
+ const tk = ticket();
+ assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } });
+ const out = await page(`/embed/signin?ticket=${tk}`);
+ assert.strictEqual(out.status, 303);
+ assert.ok(out.headers.get('location').startsWith('/auth/login?next=%2Fembed%2Fsignin%3Fticket%3D'), 'sign in first, then back');
+ const ok = await page(`/embed/signin?ticket=${tk}`, ann);
+ assert.ok(ok.status === 200 && ok.text.includes('signed in as Ann') && /frame-ancestors 'none'/.test(ok.headers.get('content-security-policy')));
+ assert.strictEqual(ok.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual(await collect(tk), { status: 200, body: { token: ann.token } });
+ assert.deepStrictEqual(await collect(tk), { status: 202, body: { pending: true } }, 'once');
+ assert.strictEqual((await collect('short')).status, 400);
+ assert.strictEqual((await page('/embed/signin?ticket=bad', ann)).status, 400);
+});
+
+t('with the token the socket joins as them: a newcomer joins, posts, and everyone reading sees it', async () => {
+ const reader = await h.ws({});
+ reader.sendJson({ type: 'join_room', room: 'pixel-fans' });
+ const r = await reader.next((m) => m.type === 'room_joined');
+ assert.strictEqual(r.can.post, false, 'anyone reads, nobody anonymous writes');
+ const tk = ticket();
+ await page(`/embed/signin?ticket=${tk}`, bob);
+ const token = (await collect(tk)).body.token;
+ const writer = await h.ws({});
+ writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });
+ const first = await writer.next((m) => m.type === 'room_joined');
+ assert.deepStrictEqual([first.role, first.can.post], [null, false], 'not a member yet');
+ assert.strictEqual((await h.http('POST', '/api/chat/rooms/pixel-fans/join', { token })).status, 200);
+ writer.sendJson({ type: 'join_room', room: 'pixel-fans', token });
+ const joined = await writer.next((m) => m.type === 'room_joined' && m.can.post);
+ assert.strictEqual(joined.role, 'member');
+ writer.sendJson({ type: 'room_message', message: 'Hi from a fan site!' });
+ const got = await reader.next((m) => m.type === 'room_message' && m.message.message === 'Hi from a fan site!');
+ assert.strictEqual(got.message.username, 'bob');
+ reader.close(); writer.close();
+});
+
+t('the tag: widget.js knows rooms (bubble or inline), and the owner\'s settings give it', async () => {
+ const js = await (await fetch(`${h.base}/widget.js`)).text();
+ assert.ok(js.includes("'/embed/room/'") && js.includes('data-target'));
+ const settings = await page('/r/pixel-fans/settings', ann);
+ assert.ok(settings.text.includes('Put this room on your site') && settings.text.includes('data-room="pixel-fans"'));
+ assert.ok(!(await page('/r/secret-club/settings', ann)).text.includes('Put this room on your site'), 'a private room has no tag');
+});
+
+t.run(async () => { require('../server/web/room-embed-pages')._reset(); if (h && h.close) await h.close(); });