openvibers/openvibe.chat Commits
Support inboxes: a signed-in visitor sends pictures from any site's chat panel (through OpenVibe.Pics as them); pictures show without their links, lists say Picture (#157)
Changes
@@@ -498,8 +498,12 @@ for people and for companies, with "direct live support with an agent", "fully m- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes toOpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the-- inbox show inline (only openvibe.pics, at most four a message). Visitors cannot upload yet: anonymous uploads wait-- for picture scanning.++ inbox show inline (only openvibe.pics, at most four a message). **A visitor signed in to OpenVibe** sends one the++ same way from the panel's picture button (`POST /api/chat/widget/inbox/:id/picture`, the body is the picture, their++ token in `X-Visitor-Token`, a line in `X-Caption`): it goes to OpenVibe.Pics as them, and their messages carry++ `account: true`, so only their Pics links show as pictures. Anonymous visitors are asked to sign in (403++ `inbox.sign_in_to_attach`) until Pics scans anonymous uploads, and a link one pastes stays a link. Wherever a++ message's pictures show, their links are not repeated as text, and lists say "📷 Picture".- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader'slanguage (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message andlanguage, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's@@@ -56,8 +56,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,HTTP, 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** (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.++ whose people do not answer) **modes** of the widget; pictures in the embed (built for signed-in visitors;++ anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation.4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack andDiscord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an@@@ -44,6 +44,9 @@ a { color: var(--accent); }.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[disabled] { opacity: .5; cursor: default; }++.em-composer button.em-attach { background: transparent; color: var(--muted); margin-left: -6px; } /* a picture: quiet, beside the box */++.em-composer button.em-attach:hover { color: var(--accent); }++.em-composer button[hidden] { display: none; }.em-foot { margin: 0 0 8px; text-align: center; font-size: .72rem; color: var(--muted); }.em-foot a { color: inherit; }.em-error { margin: 0 12px; font-size: .82rem; color: #ef4444; }@@@ -68,5 +71,6 @@ a { color: var(--accent); }.re-signin[disabled] { opacity: .65; cursor: default; }.em-head a.em-x { color: inherit; text-decoration: none; }.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }++.em-me .em-pics { justify-content: flex-end; }.em-pic { display: block; line-height: 0; border-radius: 12px; }--.em-pic img { display: block; max-width: min(240px, 100%); max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; }++.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */@@@ -12,14 +12,15 @@var thread = document.getElementById('em-thread');var form = document.getElementById('em-form');var input = form.querySelector('textarea');-- var sendBtn = form.querySelector('button');++ var sendBtn = form.querySelector('button[type=submit]');++ var attachBtn = document.getElementById('em-attach'), fileIn = document.getElementById('em-file');var personBtn = document.getElementById('em-person');var stateEl = document.getElementById('em-state');var page = new URLSearchParams(location.search).get('page') || '';var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;var people = root.getAttribute('data-people') !== '0', rateEl = null, away = null;-- // A person's reply may hold OpenVibe.Pics pictures (sent from the inbox): shown inline, only that host, at most four.++ // A person's reply, or a signed-in visitor's message, may hold OpenVibe.Pics pictures: shown inline, only that host, at most four.var picsBase = root.getAttribute('data-pics') || 'https://openvibe.pics';var PICS = new RegExp(picsBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(?:jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');function picsOf(text) {@@@ -28,6 +29,7 @@if (!box) box = el('div', 'em-pics');var a = el('a', 'em-pic'); a.href = picsBase + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';var img = el('img'); img.src = picsBase + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture'; img.loading = 'lazy';++ img.addEventListener('load', function () { if (thread.scrollHeight - thread.scrollTop - thread.clientHeight < 400) bottom(); }); // it grows the thread once loadeda.appendChild(img); box.appendChild(a); n++;}return box;@@@ -85,7 +87,7 @@function render(m) {if (m.id && thread.querySelector('[data-id="' + m.id + '"]')) return;// The visitor's own line, back over the socket before the POST answered: it is the one already shown.-- if (m.author === 'visitor') { var waiting = thread.querySelector('.em-me:not([data-id])'); if (waiting) { waiting.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }++ if (m.author === 'visitor') { var waiting = thread.querySelector('.em-me:not([data-id]):not(.em-sending)'); if (waiting) { waiting.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }if (m.id) latest = Math.max(latest, Number(m.id));var li;if (m.author === 'system') { li = el('li', 'em-system', m.body); }@@@ -93,10 +95,12 @@var mine = m.author === 'visitor';li = el('li', 'em-msg ' + (mine ? 'em-me' : 'em-them'));if (!mine) li.appendChild(el('small', null, m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));++ var pics = m.author === 'agent' || m.account ? picsOf(m.body) : null;++ var text = pics ? String(m.body).replace(PICS, '').replace(/[ \t]+$/gm, '').trim() : m.body; // the pictures show, not their linksvar b = el('div', 'em-bubble');-- if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = m.body;-- li.appendChild(b);-- if (m.author === 'agent') { var pics = picsOf(m.body); if (pics) li.appendChild(pics); }++ if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = text;++ if (text) li.appendChild(b);++ if (pics) li.appendChild(pics);if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }}if (m.id) li.dataset.id = String(m.id);@@@ -218,6 +222,35 @@}).catch(function () { pending.remove(); input.value = text; errorLine('You seem to be offline. Your message is back in the box.'); }).then(function () { sendBtn.disabled = false; input.focus(); });});++ // A picture: signed in to OpenVibe only (it goes to OpenVibe.Pics as them); what is in the box goes with it as its line.++ function sendPicture(file) {++ if (!file || !token) return;++ if (file.size > 20 * 1024 * 1024) { errorLine('That picture is too large (20 MB at most).'); return; }++ attachBtn.disabled = true; errorLine('');++ var caption = input.value.trim();++ var pending = el('li', 'em-msg em-me em-sending'); pending.appendChild(el('div', 'em-bubble', 'Sending a picture…')); thread.appendChild(pending); bottom();++ fetch(api + '/picture', { method: 'POST', headers: headers({ 'Content-Type': file.type || 'application/octet-stream', 'X-Visitor-Token': token, 'X-Caption': encodeURIComponent(caption) }), body: file })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); })++ .then(function (x) {++ pending.remove();++ if (!x.ok) { errorLine((x.d && x.d.error) || 'That picture did not send. Try again.'); return; }++ if (caption) { input.value = ''; grow(); }++ render(x.d.message);++ var fresh = !conv || conv.id !== x.d.conversation.id;++ conv = x.d.conversation; setStatus(conv.status);++ if (fresh) watch();++ if (conv.status === 'bot') showTyping('Vibe');++ })++ .catch(function () { pending.remove(); errorLine('You seem to be offline.'); })++ .then(function () { attachBtn.disabled = false; });++ }++ if (attachBtn && fileIn) {++ attachBtn.addEventListener('click', function () {++ if (!acctName) { errorLine('Sign in to send a picture: it goes to OpenVibe.Pics as you.'); if (!polling) signIn(); return; }++ fileIn.click();++ });++ fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });++ }personBtn.addEventListener('click', function () {personBtn.disabled = true;post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })@@@ -18,7 +18,7 @@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) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }-- // OpenVibe.Pics direct links in a person's reply show as pictures (only that host, at most four), like in rooms.++ // 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) {var box = null, m, n = 0; PICS.lastIndex = 0;@@@ -30,6 +30,7 @@}return box;}++ function bare(t) { return String(t || '').replace(PICS, '').replace(/[ \t]+$/gm, '').trim(); }function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }@@@ -41,15 +42,20 @@li = el('li', 'vb-turn vb-ai ib-visitor');li.appendChild(el('span', 'ib-av', (m.author_name || 'V').slice(0, 1).toUpperCase()));var body = el('div', 'vb-body'); body.appendChild(el('p', 'ib-who', m.author_name || 'Visitor'));-- body.appendChild(el('div', 'vb-md', m.body)); li.appendChild(body);++ var vp = m.account ? picsOf(m.body) : null; // a signed-in visitor's pictures show, not their links++ var said = vp ? bare(m.body) : m.body;++ if (said) body.appendChild(el('div', 'vb-md', said));++ if (vp) body.appendChild(vp);++ li.appendChild(body);} else {li = el('li', 'vb-turn vb-me');var b = el('div', 'vb-bubble' + (m.author === 'ai' ? ' ib-ai' : ''));b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));var text = el('div', 'vb-md');-- if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = m.body;-- b.appendChild(text);-- var pics = m.author === 'agent' ? picsOf(m.body) : null; if (pics) b.appendChild(pics);++ var pics = m.author === 'agent' ? picsOf(m.body) : null;++ if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = pics ? bare(m.body) : m.body;++ if (text.textContent) b.appendChild(text);++ if (pics) b.appendChild(pics);li.appendChild(b);}li.dataset.id = String(m.id);@@@ -10,6 +10,18 @@var latest = Number(cfg.latest) || 0, ws = null, delay = 1000;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; }++ // OpenVibe.Pics links from the inbox's people, or from you, 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) {++ var box = null, m, n = 0; PICS.lastIndex = 0;++ while ((m = PICS.exec(String(text || ''))) && n < 4) {++ if (!box) box = el('span', 'oc-pics');++ var a = el('a', 'oc-pic'); a.href = 'https://openvibe.pics/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';++ var img = el('img'); img.src = 'https://openvibe.pics/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy';++ a.appendChild(img); box.appendChild(a); n++;++ }++ return box;++ }function render(m) {if (!m || thread.querySelector('[data-id="' + m.id + '"]')) return;var li;@@@ -19,8 +31,10 @@li.appendChild(el('b', null, m.author === 'visitor' ? 'You' : m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));li.appendChild(document.createTextNode(' '));var t = el('span', 'sp-text');-- if (m.author === 'ai' && window.VibeMarkdown) t.innerHTML = window.VibeMarkdown.render(m.body); else t.textContent = m.body;++ var pics = m.author === 'agent' || m.account ? picsOf(m.body) : null;++ if (m.author === 'ai' && window.VibeMarkdown) t.innerHTML = window.VibeMarkdown.render(m.body); else t.textContent = pics ? String(m.body).replace(PICS, '').replace(/[ \t]+$/gm, '').trim() : m.body;li.appendChild(t);++ if (pics) li.appendChild(pics);}li.dataset.id = String(m.id);thread.appendChild(li);@@@ -121,7 +121,7 @@ const ERASE_AFTER_ROOMS = [['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],// As a visitor of others' inboxes: their conversations stay with those teams, without them.-- [null, "UPDATE inbox_messages SET author_name = NULL WHERE author = 'visitor' AND conversation_id IN (SELECT id FROM inbox_conversations WHERE visitor_user_id = ANY(@ids))"],++ [null, "UPDATE inbox_messages SET author_name = NULL, author_user_id = NULL WHERE author = 'visitor' AND (author_user_id = ANY(@ids) OR conversation_id IN (SELECT id FROM inbox_conversations WHERE visitor_user_id = ANY(@ids)))"],[null, "UPDATE inbox_conversations SET visitor_user_id = NULL, visitor_subject = NULL, visitor_name = 'A former visitor', visitor_email = NULL, external_id = NULL WHERE visitor_user_id = ANY(@ids)"],// Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],@@@ -43,7 +43,7 @@ function cleanOrigin(o) { try { const u = new URL(String(o).trim()); return /^htfunction cleanUrl(u) { try { const x = new URL(String(u).trim()); return x.protocol === 'https:' ? x.href : null; } catch { return null; } }// A message as one line of plain text, for a list's preview: Vibe writes Markdown, which would show as ** and [](…).function plainText(body) {-- return String(body || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim();++ return require('../rooms/pictures').withoutLinks(body, '📷 Picture').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim();}/** The page a visitor wrote from (http or https, at most 500 characters), or null. */function pageUrlOf(u) { try { const x = new URL(String(u || '').trim()); return /^https?:$/.test(x.protocol) ? x.href.slice(0, 500) : null; } catch { return null; } }@@@ -196,7 +196,8 @@ const convShape = (c) => c && ({verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,});--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 });++// 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 } : {}) });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 *`,@@@ -359,7 +360,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = nullrequire('./webhooks').later(inbox.id, 'conversation.created', { conversationId: conv.id });}const filtered = wordFilter.check(body);-- const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);++ const signedIn = !!account && (accountToken(inbox, account) === String(token) || Number(conv.visitor_user_id) === Number(account.id));++ const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name, userId: signedIn ? account.id : null }, deps);// A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.if (started && conv.status === 'waiting') await takeTurn(conv, deps);if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');@@@ -11,6 +11,8 @@* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)++ * POST /picture the body is a picture; X-Visitor-Token, an optional line in X-Caption → 201 { conversation, message,++ * picture } (signed in to OpenVibe only: it goes to OpenVibe.Pics as them; 403 inbox.sign_in_to_attach)** /api/chat/support — a visitor's own conversations with sites' chats (signed in to OpenVibe in the panel):* GET / { conversations }: newest first, with the inbox's name@@@ -179,6 +181,27 @@ agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req,if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, `${caption ? `${caption}\n` : ''}${out.picture.direct}`), picture: out.picture }); } catch (err) { send(res, err); }});++// A signed-in visitor's picture (the panel's attach button): it goes to OpenVibe.Pics as them, the same way, and the++// conversation gets their message with its link. Anonymous visitors cannot upload until Pics scans pictures.++widget.post('/picture', pictureLimit, async (req, res, next) => {++ const inbox = await inboxOr404(req, res); if (!inbox) return;++ if (!/^[A-Za-z0-9_-]{32}$/.test(tokenOf(req))) return res.status(401).json({ error: 'Open the chat again to start a conversation', code: 'inbox.visitor' });++ const account = await accountOf(req);++ if (!account) return res.status(403).json({ error: 'Sign in to send a picture', code: 'inbox.sign_in_to_attach' });++ req.inbox = inbox; req.account = account;++ return express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {++ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });++ return next(err);++ });++}, async (req, res) => {++ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();++ const out = await require('../rooms/pictures').upload({ token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), title: caption, ip: req.ip });++ if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });++ try {++ const posted = await inboxes.visitorPost(req.inbox, tokenOf(req), { message: `${caption ? `${caption}\n` : ''}${out.picture.direct}`, account: req.account });++ res.status(201).json({ ...posted, picture: out.picture });++ } catch (err) { send(res, err); }++});const translateLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });agents.post('/:id/conversations/:cid/messages/:mid/translate', requireAuth, translateLimit, async (req, res) => {const conv = await agentConversation(req, res); if (!conv) return;@@@ -47,6 +47,11 @@ function linksIn(text) {return out;}++/** The text without its Pics links (shown as pictures instead), each replaced by `instead`. */++function withoutLinks(text, instead = '') {++ return String(text || '').replace(linkPattern(), instead).replace(/[ \t]+$/gm, '').trim();++}++/** The inline thumbnails for a message, as HTML (empty when it links no Pics picture). */function pageHtml(text) {const pics = linksIn(text);@@@ -114,4 +119,4 @@ function forModel({ slug, ext }) {});}--module.exports = { enabled, upload, linksIn, pageHtml, publicUrl, forModel, TYPES, MAX_INLINE, MODEL_MAX_BYTES };++module.exports = { enabled, upload, linksIn, withoutLinks, pageHtml, publicUrl, forModel, TYPES, MAX_INLINE, MODEL_MAX_BYTES };@@@ -52,6 +52,7 @@ function embedHtml(inbox) {<ol class="em-thread" id="em-thread" aria-live="polite"><li class="em-msg em-them"><small>${esc(inbox.name)}</small><div class="em-bubble">${esc(inbox.greeting)}</div></li></ol><div class="em-handoff"><button type="button" id="em-person" hidden>Talk to a person</button></div><form class="em-composer" id="em-form"><label class="em-sr" for="em-input" hidden>Your message</label>++${pictures.enabled() ? '<button type="button" class="em-attach" id="em-attach" aria-label="Send a picture" title="Send a picture"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/></svg></button><input type="file" id="em-file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden>' : ''}<textarea id="em-input" rows="1" maxlength="4000" placeholder="Write a message" aria-label="Your message"></textarea><button type="submit" aria-label="Send">↑</button></form><p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a>${Number(inbox.ai_enabled) ? ' · Vibe can get things wrong' : ''}<span id="em-account"></span></p>@@@ -132,10 +133,12 @@ ${list}const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}"><span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span><span class="ib-prev">${c.assignee_id && c.status !== 'resolved' ? `<span class="ib-who-has">${esc(Number(c.assignee_id) === Number(actor.user.id) ? 'You' : nameOf(c.assignee_id) || 'Someone')}</span> · ` : ''}${esc(c.preview || '')}</span></a></li>`;++ // A person's or a signed-in visitor's message shows its pictures inline, not their links as text.++ const shown = (m) => (m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body);const turn = (m) => {if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;-- if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;-- return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;++ if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p>${shown(m) ? `<div class="vb-md">${esc(shown(m)).replace(/\n/g, '<br>')}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}</div></li>`;++ return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(shown(m)).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;};const tabs = [['active', 'Active'], ['mine', 'Mine'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';@@@ -286,7 +289,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.body).replace(/\n/g, '<br>')}</span>${m.author === 'agent' ? 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) : ''}</li>`);await page(req, res, 200, {actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow',page: { view: 'support', inbox: inbox.id, conversation: conv.id, latest: msgs.length ? msgs[msgs.length - 1].id : 0 },@@@ -431,6 +431,33 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messaintruder.close();});++t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as them); anyone else is asked to sign in, and their links stay links', async () => {++ const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');++ const picture = (headers) => fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}/picture`, { method: 'POST', headers: { 'content-type': 'image/png', ...headers }, body: PNG })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ const anon = (await widget('POST', '/visit', {})).body.token;++ assert.deepStrictEqual([(await picture({ 'x-visitor-token': anon })).status, (await picture({ 'x-visitor-token': anon })).body.code], [403, 'inbox.sign_in_to_attach']);++ const pasted = await widget('POST', '/messages', { token: anon, message: 'Look https://openvibe.pics/i/Pk222222.png' });++ assert.strictEqual(pasted.body.message.account, undefined, 'an anonymous visitor\'s link is only a link');++ const eve = h.addUser('eve', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(eve.id));++ const as = { authorization: `Bearer ${eve.token}` };++ const token = (await widget('POST', '/visit', {}, as)).body.token;++ assert.strictEqual((await picture({ ...as, 'x-visitor-token': 'short' })).status, 401);++ const sent = await picture({ ...as, 'x-visitor-token': token, 'x-caption': encodeURIComponent('The crack, here') });++ assert.strictEqual(sent.status, 201, JSON.stringify(sent.body));++ assert.ok(/^The crack, here\nhttps:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/.test(sent.body.message.body), sent.body.message.body);++ assert.deepStrictEqual([sent.body.message.author, sent.body.message.account], ['visitor', true]);++ const cid = sent.body.conversation.id;++ const typed = await widget('POST', '/messages', { token, message: 'And a second line' }, as);++ assert.strictEqual(typed.body.message.account, true, 'everything they write signed in is marked');++ const page = (path, u) => fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` } }).then((x) => x.text());++ assert.ok((await page(`/inbox/${inbox.id}?c=${cid}`, bob)).includes('class="oc-pic"'), 'the people see it inline');++ assert.ok(!(await page(`/inbox/${inbox.id}?c=${pasted.body.conversation.id}`, bob)).includes('class="oc-pic"'), 'and the pasted link as text');++ assert.ok((await page(`/support/${cid}`, eve)).includes('class="oc-pic"'), 'the visitor sees it in their Messages too');++ assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');++});++t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');
@@ -498,8 +498,12 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -498,8 +498,12 @@ for people and for companies, with "direct live support with an agent", "fully m
- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
- inbox show inline (only openvibe.pics, at most four a message). Visitors cannot upload yet: anonymous uploads wait
- for picture scanning.
+ inbox show inline (only openvibe.pics, at most four a message). **A visitor signed in to OpenVibe** sends one the
+ same way from the panel's picture button (`POST /api/chat/widget/inbox/:id/picture`, the body is the picture, their
+ token in `X-Visitor-Token`, a line in `X-Caption`): it goes to OpenVibe.Pics as them, and their messages carry
+ `account: true`, so only their Pics links show as pictures. Anonymous visitors are asked to sign in (403
+ `inbox.sign_in_to_attach`) until Pics scans anonymous uploads, and a link one pastes stays a link. Wherever a
+ message's pictures show, their links are not repeated as text, and lists say "📷 Picture".
- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's
- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's
language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and
language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and
language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's
language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's
@@ -56,8 +56,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
@@ -56,8 +56,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
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** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox
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.
+ whose people do not answer) **modes** of the widget; pictures in the embed (built for signed-in visitors;
+ anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation.
4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,
4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,
`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
@@ -44,6 +44,9 @@ a { color: var(--accent); }
@@ -44,6 +44,9 @@ a { color: var(--accent); }
.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; }
.em-composer button[disabled] { opacity: .5; cursor: default; }
.em-composer button[disabled] { opacity: .5; cursor: default; }
+.em-composer button.em-attach { background: transparent; color: var(--muted); margin-left: -6px; } /* a picture: quiet, beside the box */
+.em-composer button.em-attach:hover { color: var(--accent); }
+.em-composer button[hidden] { display: none; }
.em-foot { margin: 0 0 8px; text-align: center; font-size: .72rem; color: var(--muted); }
.em-foot { margin: 0 0 8px; text-align: center; font-size: .72rem; color: var(--muted); }
.em-foot a { color: inherit; }
.em-foot a { color: inherit; }
.em-error { margin: 0 12px; font-size: .82rem; color: #ef4444; }
.em-error { margin: 0 12px; font-size: .82rem; color: #ef4444; }
@@ -68,5 +71,6 @@ a { color: var(--accent); }
@@ -68,5 +71,6 @@ a { color: var(--accent); }
.re-signin[disabled] { opacity: .65; cursor: default; }
.re-signin[disabled] { opacity: .65; cursor: default; }
.em-head a.em-x { color: inherit; text-decoration: none; }
.em-head a.em-x { color: inherit; text-decoration: none; }
.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.em-me .em-pics { justify-content: flex-end; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
-.em-pic img { display: block; max-width: min(240px, 100%); max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; }
+.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */
@@ -12,14 +12,15 @@
@@ -12,14 +12,15 @@
var thread = document.getElementById('em-thread');
var thread = document.getElementById('em-thread');
var form = document.getElementById('em-form');
var form = document.getElementById('em-form');
var input = form.querySelector('textarea');
var input = form.querySelector('textarea');
- var sendBtn = form.querySelector('button');
+ var sendBtn = form.querySelector('button[type=submit]');
+ var attachBtn = document.getElementById('em-attach'), fileIn = document.getElementById('em-file');
var personBtn = document.getElementById('em-person');
var personBtn = document.getElementById('em-person');
var stateEl = document.getElementById('em-state');
var stateEl = document.getElementById('em-state');
var page = new URLSearchParams(location.search).get('page') || '';
var page = new URLSearchParams(location.search).get('page') || '';
var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;
var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;
var people = root.getAttribute('data-people') !== '0', rateEl = null, away = null;
var people = root.getAttribute('data-people') !== '0', rateEl = null, away = null;
- // A person's reply may hold OpenVibe.Pics pictures (sent from the inbox): shown inline, only that host, at most four.
+ // A person's reply, or a signed-in visitor's message, may hold OpenVibe.Pics pictures: shown inline, only that host, at most four.
var picsBase = root.getAttribute('data-pics') || 'https://openvibe.pics';
var picsBase = root.getAttribute('data-pics') || 'https://openvibe.pics';
var PICS = new RegExp(picsBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(?:jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');
var PICS = new RegExp(picsBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(?:jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');
function picsOf(text) {
function picsOf(text) {
@@ -28,6 +29,7 @@
@@ -28,6 +29,7 @@
if (!box) box = el('div', 'em-pics');
if (!box) box = el('div', 'em-pics');
var a = el('a', 'em-pic'); a.href = picsBase + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
var a = el('a', 'em-pic'); a.href = picsBase + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
var img = el('img'); img.src = picsBase + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture'; img.loading = 'lazy';
var img = el('img'); img.src = picsBase + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture'; img.loading = 'lazy';
+ img.addEventListener('load', function () { if (thread.scrollHeight - thread.scrollTop - thread.clientHeight < 400) bottom(); }); // it grows the thread once loaded
a.appendChild(img); box.appendChild(a); n++;
a.appendChild(img); box.appendChild(a); n++;
}
}
return box;
return box;
@@ -85,7 +87,7 @@
@@ -85,7 +87,7 @@
function render(m) {
function render(m) {
if (m.id && thread.querySelector('[data-id="' + m.id + '"]')) return;
if (m.id && thread.querySelector('[data-id="' + m.id + '"]')) return;
// The visitor's own line, back over the socket before the POST answered: it is the one already shown.
// The visitor's own line, back over the socket before the POST answered: it is the one already shown.
- if (m.author === 'visitor') { var waiting = thread.querySelector('.em-me:not([data-id])'); if (waiting) { waiting.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
+ if (m.author === 'visitor') { var waiting = thread.querySelector('.em-me:not([data-id]):not(.em-sending)'); if (waiting) { waiting.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
if (m.id) latest = Math.max(latest, Number(m.id));
if (m.id) latest = Math.max(latest, Number(m.id));
var li;
var li;
if (m.author === 'system') { li = el('li', 'em-system', m.body); }
if (m.author === 'system') { li = el('li', 'em-system', m.body); }
@@ -93,10 +95,12 @@
@@ -93,10 +95,12 @@
var mine = m.author === 'visitor';
var mine = m.author === 'visitor';
li = el('li', 'em-msg ' + (mine ? 'em-me' : 'em-them'));
li = el('li', 'em-msg ' + (mine ? 'em-me' : 'em-them'));
if (!mine) li.appendChild(el('small', null, m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));
if (!mine) li.appendChild(el('small', null, m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));
+ var pics = m.author === 'agent' || m.account ? picsOf(m.body) : null;
+ var text = pics ? String(m.body).replace(PICS, '').replace(/[ \t]+$/gm, '').trim() : m.body; // the pictures show, not their links
var b = el('div', 'em-bubble');
var b = el('div', 'em-bubble');
- if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = m.body;
- li.appendChild(b);
- if (m.author === 'agent') { var pics = picsOf(m.body); if (pics) li.appendChild(pics); }
+ if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = text;
+ if (text) li.appendChild(b);
+ if (pics) li.appendChild(pics);
if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }
if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }
}
}
if (m.id) li.dataset.id = String(m.id);
if (m.id) li.dataset.id = String(m.id);
@@ -218,6 +222,35 @@
@@ -218,6 +222,35 @@
}).catch(function () { pending.remove(); input.value = text; errorLine('You seem to be offline. Your message is back in the box.'); })
}).catch(function () { pending.remove(); input.value = text; errorLine('You seem to be offline. Your message is back in the box.'); })
.then(function () { sendBtn.disabled = false; input.focus(); });
.then(function () { sendBtn.disabled = false; input.focus(); });
});
});
+ // A picture: signed in to OpenVibe only (it goes to OpenVibe.Pics as them); what is in the box goes with it as its line.
+ function sendPicture(file) {
+ if (!file || !token) return;
+ if (file.size > 20 * 1024 * 1024) { errorLine('That picture is too large (20 MB at most).'); return; }
+ attachBtn.disabled = true; errorLine('');
+ var caption = input.value.trim();
+ var pending = el('li', 'em-msg em-me em-sending'); pending.appendChild(el('div', 'em-bubble', 'Sending a picture…')); thread.appendChild(pending); bottom();
+ fetch(api + '/picture', { method: 'POST', headers: headers({ 'Content-Type': file.type || 'application/octet-stream', 'X-Visitor-Token': token, 'X-Caption': encodeURIComponent(caption) }), body: file })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); })
+ .then(function (x) {
+ pending.remove();
+ if (!x.ok) { errorLine((x.d && x.d.error) || 'That picture did not send. Try again.'); return; }
+ if (caption) { input.value = ''; grow(); }
+ render(x.d.message);
+ var fresh = !conv || conv.id !== x.d.conversation.id;
+ conv = x.d.conversation; setStatus(conv.status);
+ if (fresh) watch();
+ if (conv.status === 'bot') showTyping('Vibe');
+ })
+ .catch(function () { pending.remove(); errorLine('You seem to be offline.'); })
+ .then(function () { attachBtn.disabled = false; });
+ }
+ if (attachBtn && fileIn) {
+ attachBtn.addEventListener('click', function () {
+ if (!acctName) { errorLine('Sign in to send a picture: it goes to OpenVibe.Pics as you.'); if (!polling) signIn(); return; }
+ fileIn.click();
+ });
+ fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });
+ }
personBtn.addEventListener('click', function () {
personBtn.addEventListener('click', function () {
personBtn.disabled = true;
personBtn.disabled = true;
post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })
post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
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) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }
function chip(s) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }
- // OpenVibe.Pics direct links in a person's reply show as pictures (only that host, at most four), like in rooms.
+ // 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) {
var box = null, m, n = 0; PICS.lastIndex = 0;
var box = null, m, n = 0; PICS.lastIndex = 0;
@@ -30,6 +30,7 @@
@@ -30,6 +30,7 @@
}
}
return box;
return box;
}
}
+ function bare(t) { return String(t || '').replace(PICS, '').replace(/[ \t]+$/gm, '').trim(); }
function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }
function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }
function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }
function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
@@ -41,15 +42,20 @@
@@ -41,15 +42,20 @@
li = el('li', 'vb-turn vb-ai ib-visitor');
li = el('li', 'vb-turn vb-ai ib-visitor');
li.appendChild(el('span', 'ib-av', (m.author_name || 'V').slice(0, 1).toUpperCase()));
li.appendChild(el('span', 'ib-av', (m.author_name || 'V').slice(0, 1).toUpperCase()));
var body = el('div', 'vb-body'); body.appendChild(el('p', 'ib-who', m.author_name || 'Visitor'));
var body = el('div', 'vb-body'); body.appendChild(el('p', 'ib-who', m.author_name || 'Visitor'));
- body.appendChild(el('div', 'vb-md', m.body)); li.appendChild(body);
+ var vp = m.account ? picsOf(m.body) : null; // a signed-in visitor's pictures show, not their links
+ var said = vp ? bare(m.body) : m.body;
+ if (said) body.appendChild(el('div', 'vb-md', said));
+ if (vp) body.appendChild(vp);
+ li.appendChild(body);
} else {
} else {
li = el('li', 'vb-turn vb-me');
li = el('li', 'vb-turn vb-me');
var b = el('div', 'vb-bubble' + (m.author === 'ai' ? ' ib-ai' : ''));
var b = el('div', 'vb-bubble' + (m.author === 'ai' ? ' ib-ai' : ''));
b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));
b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));
var text = el('div', 'vb-md');
var text = el('div', 'vb-md');
- if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = m.body;
- b.appendChild(text);
- var pics = m.author === 'agent' ? picsOf(m.body) : null; if (pics) b.appendChild(pics);
+ var pics = m.author === 'agent' ? picsOf(m.body) : null;
+ if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = pics ? bare(m.body) : m.body;
+ if (text.textContent) b.appendChild(text);
+ if (pics) b.appendChild(pics);
li.appendChild(b);
li.appendChild(b);
}
}
li.dataset.id = String(m.id);
li.dataset.id = String(m.id);
@@ -10,6 +10,18 @@
@@ -10,6 +10,18 @@
var latest = Number(cfg.latest) || 0, ws = null, delay = 1000;
var latest = Number(cfg.latest) || 0, ws = null, delay = 1000;
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; }
+ // OpenVibe.Pics links from the inbox's people, or from you, 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) {
+ var box = null, m, n = 0; PICS.lastIndex = 0;
+ while ((m = PICS.exec(String(text || ''))) && n < 4) {
+ if (!box) box = el('span', 'oc-pics');
+ var a = el('a', 'oc-pic'); a.href = 'https://openvibe.pics/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
+ var img = el('img'); img.src = 'https://openvibe.pics/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy';
+ a.appendChild(img); box.appendChild(a); n++;
+ }
+ return box;
+ }
function render(m) {
function render(m) {
if (!m || thread.querySelector('[data-id="' + m.id + '"]')) return;
if (!m || thread.querySelector('[data-id="' + m.id + '"]')) return;
var li;
var li;
@@ -19,8 +31,10 @@
@@ -19,8 +31,10 @@
li.appendChild(el('b', null, m.author === 'visitor' ? 'You' : m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));
li.appendChild(el('b', null, m.author === 'visitor' ? 'You' : m.author === 'ai' ? 'Vibe' : (m.author_name || 'Support')));
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'sp-text');
var t = el('span', 'sp-text');
- if (m.author === 'ai' && window.VibeMarkdown) t.innerHTML = window.VibeMarkdown.render(m.body); else t.textContent = m.body;
+ var pics = m.author === 'agent' || m.account ? picsOf(m.body) : null;
+ if (m.author === 'ai' && window.VibeMarkdown) t.innerHTML = window.VibeMarkdown.render(m.body); else t.textContent = pics ? String(m.body).replace(PICS, '').replace(/[ \t]+$/gm, '').trim() : m.body;
li.appendChild(t);
li.appendChild(t);
+ if (pics) li.appendChild(pics);
}
}
li.dataset.id = String(m.id);
li.dataset.id = String(m.id);
thread.appendChild(li);
thread.appendChild(li);
@@ -121,7 +121,7 @@ const ERASE_AFTER_ROOMS = [
@@ -121,7 +121,7 @@ const ERASE_AFTER_ROOMS = [
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
// As a visitor of others' inboxes: their conversations stay with those teams, without them.
// As a visitor of others' inboxes: their conversations stay with those teams, without them.
- [null, "UPDATE inbox_messages SET author_name = NULL WHERE author = 'visitor' AND conversation_id IN (SELECT id FROM inbox_conversations WHERE visitor_user_id = ANY(@ids))"],
+ [null, "UPDATE inbox_messages SET author_name = NULL, author_user_id = NULL WHERE author = 'visitor' AND (author_user_id = ANY(@ids) OR conversation_id IN (SELECT id FROM inbox_conversations WHERE visitor_user_id = ANY(@ids)))"],
[null, "UPDATE inbox_conversations SET visitor_user_id = NULL, visitor_subject = NULL, visitor_name = 'A former visitor', visitor_email = NULL, external_id = NULL WHERE visitor_user_id = ANY(@ids)"],
[null, "UPDATE inbox_conversations SET visitor_user_id = NULL, visitor_subject = NULL, visitor_name = 'A former visitor', visitor_email = NULL, external_id = NULL WHERE visitor_user_id = ANY(@ids)"],
// Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.
// Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.
['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
@@ -43,7 +43,7 @@ function cleanOrigin(o) { try { const u = new URL(String(o).trim()); return /^ht
@@ -43,7 +43,7 @@ function cleanOrigin(o) { try { const u = new URL(String(o).trim()); return /^ht
function cleanUrl(u) { try { const x = new URL(String(u).trim()); return x.protocol === 'https:' ? x.href : null; } catch { return null; } }
function cleanUrl(u) { try { const x = new URL(String(u).trim()); return x.protocol === 'https:' ? x.href : null; } catch { return null; } }
// A message as one line of plain text, for a list's preview: Vibe writes Markdown, which would show as ** and [](…).
// A message as one line of plain text, for a list's preview: Vibe writes Markdown, which would show as ** and [](…).
function plainText(body) {
function plainText(body) {
- return String(body || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim();
+ return require('../rooms/pictures').withoutLinks(body, '📷 Picture').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim();
}
}
/** The page a visitor wrote from (http or https, at most 500 characters), or null. */
/** The page a visitor wrote from (http or https, at most 500 characters), or null. */
function pageUrlOf(u) { try { const x = new URL(String(u || '').trim()); return /^https?:$/.test(x.protocol) ? x.href.slice(0, 500) : null; } catch { return null; } }
function pageUrlOf(u) { try { const x = new URL(String(u || '').trim()); return /^https?:$/.test(x.protocol) ? x.href.slice(0, 500) : null; } catch { return null; } }
@@ -196,7 +196,8 @@ const convShape = (c) => c && ({
@@ -196,7 +196,8 @@ const convShape = (c) => c && ({
verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,
verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,
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,
});
});
-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 });
+// 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 } : {}) });
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 *`,
const row = await db.get(`INSERT INTO inbox_messages (conversation_id, author, author_user_id, author_name, body) VALUES (?, ?, ?, ?, ?) RETURNING *`,
@@ -359,7 +360,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
@@ -359,7 +360,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
require('./webhooks').later(inbox.id, 'conversation.created', { conversationId: conv.id });
require('./webhooks').later(inbox.id, 'conversation.created', { conversationId: conv.id });
}
}
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
- const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);
+ const signedIn = !!account && (accountToken(inbox, account) === String(token) || Number(conv.visitor_user_id) === Number(account.id));
+ const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name, userId: signedIn ? account.id : null }, deps);
// A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.
// A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.
if (started && conv.status === 'waiting') await takeTurn(conv, deps);
if (started && conv.status === 'waiting') await takeTurn(conv, deps);
if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');
if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }
* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }
* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)
* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)
* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)
* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)
+ * POST /picture the body is a picture; X-Visitor-Token, an optional line in X-Caption → 201 { conversation, message,
+ * picture } (signed in to OpenVibe only: it goes to OpenVibe.Pics as them; 403 inbox.sign_in_to_attach)
*
*
* /api/chat/support — a visitor's own conversations with sites' chats (signed in to OpenVibe in the panel):
* /api/chat/support — a visitor's own conversations with sites' chats (signed in to OpenVibe in the panel):
* GET / { conversations }: newest first, with the inbox's name
* GET / { conversations }: newest first, with the inbox's name
@@ -179,6 +181,27 @@ agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req,
@@ -179,6 +181,27 @@ agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req,
if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });
if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });
try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, `${caption ? `${caption}\n` : ''}${out.picture.direct}`), picture: out.picture }); } catch (err) { send(res, err); }
try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, `${caption ? `${caption}\n` : ''}${out.picture.direct}`), picture: out.picture }); } catch (err) { send(res, err); }
});
});
+// A signed-in visitor's picture (the panel's attach button): it goes to OpenVibe.Pics as them, the same way, and the
+// conversation gets their message with its link. Anonymous visitors cannot upload until Pics scans pictures.
+widget.post('/picture', pictureLimit, async (req, res, next) => {
+ const inbox = await inboxOr404(req, res); if (!inbox) return;
+ if (!/^[A-Za-z0-9_-]{32}$/.test(tokenOf(req))) return res.status(401).json({ error: 'Open the chat again to start a conversation', code: 'inbox.visitor' });
+ const account = await accountOf(req);
+ if (!account) return res.status(403).json({ error: 'Sign in to send a picture', code: 'inbox.sign_in_to_attach' });
+ req.inbox = inbox; req.account = account;
+ return express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
+ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });
+ return next(err);
+ });
+}, async (req, res) => {
+ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();
+ const out = await require('../rooms/pictures').upload({ token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), title: caption, ip: req.ip });
+ if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });
+ try {
+ const posted = await inboxes.visitorPost(req.inbox, tokenOf(req), { message: `${caption ? `${caption}\n` : ''}${out.picture.direct}`, account: req.account });
+ res.status(201).json({ ...posted, picture: out.picture });
+ } catch (err) { send(res, err); }
+});
const translateLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
const translateLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
agents.post('/:id/conversations/:cid/messages/:mid/translate', requireAuth, translateLimit, async (req, res) => {
agents.post('/:id/conversations/:cid/messages/:mid/translate', requireAuth, translateLimit, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
const conv = await agentConversation(req, res); if (!conv) return;
@@ -47,6 +47,11 @@ function linksIn(text) {
@@ -47,6 +47,11 @@ function linksIn(text) {
return out;
return out;
}
}
+/** The text without its Pics links (shown as pictures instead), each replaced by `instead`. */
+function withoutLinks(text, instead = '') {
+ return String(text || '').replace(linkPattern(), instead).replace(/[ \t]+$/gm, '').trim();
+}
+
/** The inline thumbnails for a message, as HTML (empty when it links no Pics picture). */
/** The inline thumbnails for a message, as HTML (empty when it links no Pics picture). */
function pageHtml(text) {
function pageHtml(text) {
const pics = linksIn(text);
const pics = linksIn(text);
@@ -114,4 +119,4 @@ function forModel({ slug, ext }) {
@@ -114,4 +119,4 @@ function forModel({ slug, ext }) {
});
});
}
}
-module.exports = { enabled, upload, linksIn, pageHtml, publicUrl, forModel, TYPES, MAX_INLINE, MODEL_MAX_BYTES };
+module.exports = { enabled, upload, linksIn, withoutLinks, pageHtml, publicUrl, forModel, TYPES, MAX_INLINE, MODEL_MAX_BYTES };
@@ -52,6 +52,7 @@ function embedHtml(inbox) {
@@ -52,6 +52,7 @@ function embedHtml(inbox) {
<ol class="em-thread" id="em-thread" aria-live="polite"><li class="em-msg em-them"><small>${esc(inbox.name)}</small><div class="em-bubble">${esc(inbox.greeting)}</div></li></ol>
<ol class="em-thread" id="em-thread" aria-live="polite"><li class="em-msg em-them"><small>${esc(inbox.name)}</small><div class="em-bubble">${esc(inbox.greeting)}</div></li></ol>
<div class="em-handoff"><button type="button" id="em-person" hidden>Talk to a person</button></div>
<div class="em-handoff"><button type="button" id="em-person" hidden>Talk to a person</button></div>
<form class="em-composer" id="em-form"><label class="em-sr" for="em-input" hidden>Your message</label>
<form class="em-composer" id="em-form"><label class="em-sr" for="em-input" hidden>Your message</label>
+${pictures.enabled() ? '<button type="button" class="em-attach" id="em-attach" aria-label="Send a picture" title="Send a picture"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/></svg></button><input type="file" id="em-file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden>' : ''}
<textarea id="em-input" rows="1" maxlength="4000" placeholder="Write a message" aria-label="Your message"></textarea>
<textarea id="em-input" rows="1" maxlength="4000" placeholder="Write a message" aria-label="Your message"></textarea>
<button type="submit" aria-label="Send">↑</button></form>
<button type="submit" aria-label="Send">↑</button></form>
<p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a>${Number(inbox.ai_enabled) ? ' · Vibe can get things wrong' : ''}<span id="em-account"></span></p>
<p class="em-foot">Chat by <a href="https://openvibe.chat" target="_blank" rel="noopener">OpenVibe</a>${Number(inbox.ai_enabled) ? ' · Vibe can get things wrong' : ''}<span id="em-account"></span></p>
@@ -132,10 +133,12 @@ ${list}
@@ -132,10 +133,12 @@ ${list}
const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}">
const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}">
<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
<span class="ib-prev">${c.assignee_id && c.status !== 'resolved' ? `<span class="ib-who-has">${esc(Number(c.assignee_id) === Number(actor.user.id) ? 'You' : nameOf(c.assignee_id) || 'Someone')}</span> · ` : ''}${esc(c.preview || '')}</span></a></li>`;
<span class="ib-prev">${c.assignee_id && c.status !== 'resolved' ? `<span class="ib-who-has">${esc(Number(c.assignee_id) === Number(actor.user.id) ? 'You' : nameOf(c.assignee_id) || 'Someone')}</span> · ` : ''}${esc(c.preview || '')}</span></a></li>`;
+ // A person's or a signed-in visitor's message shows its pictures inline, not their links as text.
+ const shown = (m) => (m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body);
const turn = (m) => {
const turn = (m) => {
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
- if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;
- return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;
+ if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p>${shown(m) ? `<div class="vb-md">${esc(shown(m)).replace(/\n/g, '<br>')}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}</div></li>`;
+ return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(shown(m)).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;
};
};
const tabs = [['active', 'Active'], ['mine', 'Mine'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
const tabs = [['active', 'Active'], ['mine', 'Mine'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
@@ -286,7 +289,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
@@ -286,7 +289,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.body).replace(/\n/g, '<br>')}</span>${m.author === 'agent' ? 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) : ''}</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',
actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow',
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 },
@@ -431,6 +431,33 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messa
@@ -431,6 +431,33 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messa
intruder.close();
intruder.close();
});
});
+t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as them); anyone else is asked to sign in, and their links stay links', async () => {
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
+ const picture = (headers) => fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}/picture`, { method: 'POST', headers: { 'content-type': 'image/png', ...headers }, body: PNG })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ const anon = (await widget('POST', '/visit', {})).body.token;
+ assert.deepStrictEqual([(await picture({ 'x-visitor-token': anon })).status, (await picture({ 'x-visitor-token': anon })).body.code], [403, 'inbox.sign_in_to_attach']);
+ const pasted = await widget('POST', '/messages', { token: anon, message: 'Look https://openvibe.pics/i/Pk222222.png' });
+ assert.strictEqual(pasted.body.message.account, undefined, 'an anonymous visitor\'s link is only a link');
+ const eve = h.addUser('eve', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(eve.id));
+ const as = { authorization: `Bearer ${eve.token}` };
+ const token = (await widget('POST', '/visit', {}, as)).body.token;
+ assert.strictEqual((await picture({ ...as, 'x-visitor-token': 'short' })).status, 401);
+ const sent = await picture({ ...as, 'x-visitor-token': token, 'x-caption': encodeURIComponent('The crack, here') });
+ assert.strictEqual(sent.status, 201, JSON.stringify(sent.body));
+ assert.ok(/^The crack, here\nhttps:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/.test(sent.body.message.body), sent.body.message.body);
+ assert.deepStrictEqual([sent.body.message.author, sent.body.message.account], ['visitor', true]);
+ const cid = sent.body.conversation.id;
+ const typed = await widget('POST', '/messages', { token, message: 'And a second line' }, as);
+ assert.strictEqual(typed.body.message.account, true, 'everything they write signed in is marked');
+ const page = (path, u) => fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` } }).then((x) => x.text());
+ assert.ok((await page(`/inbox/${inbox.id}?c=${cid}`, bob)).includes('class="oc-pic"'), 'the people see it inline');
+ assert.ok(!(await page(`/inbox/${inbox.id}?c=${pasted.body.conversation.id}`, bob)).includes('class="oc-pic"'), 'and the pasted link as text');
+ assert.ok((await page(`/support/${cid}`, eve)).includes('class="oc-pic"'), 'the visitor sees it in their Messages too');
+ assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');
+});
+
t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {
t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');