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)

OP OpenVibers committed 19 days ago · 92d9e24f6f · 12 files, +146 −25

Changes

ddiff --git a/README.md b/README.md
iindex f67fa1c..802f576 100644
---- a/README.md
++++ b/README.md
@@@ -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 to
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
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
ddiff --git a/docs/platform.md b/docs/platform.md
iindex 12e6168..cd903cb 100644
---- a/docs/platform.md
++++ b/docs/platform.md
@@@ -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
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
-- 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 and
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
ddiff --git a/public/web/embed.css b/public/web/embed.css
iindex c9b5a67..eaa0967 100644
---- a/public/web/embed.css
++++ b/public/web/embed.css
@@@ -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 */
ddiff --git a/public/web/embed.js b/public/web/embed.js
iindex 76d9469..ce19ef0 100644
---- a/public/web/embed.js
++++ b/public/web/embed.js
@@@ -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 loaded
a.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 links
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 (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.'); })
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex e636533..9dddb62 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -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);
ddiff --git a/public/web/support.js b/public/web/support.js
iindex 0787010..0ba43e8 100644
---- a/public/web/support.js
++++ b/public/web/support.js
@@@ -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);
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex b03835e..0a99008 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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))'],
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex 468f61e..3708328 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -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; } }
// 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 = null
require('./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');
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex bbeecc4..8435ab9 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -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;
ddiff --git a/server/rooms/pictures.js b/server/rooms/pictures.js
iindex 1b5ac11..c0b4174 100644
---- a/server/rooms/pictures.js
++++ b/server/rooms/pictures.js
@@@ -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 };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 25e777c..29307be 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -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">&#8593;</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)}&amp;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.sho
const msgs = await inboxes.messages(conv);
const who = (m) => (m.author === 'visitor' ? 'You' : m.author === 'ai' ? 'Vibe' : m.author === 'system' ? '' : (m.author_name || inbox.name));
const line = (m) => (m.author === 'system' ? `<li class="oc-muted sp-sys" data-id="${m.id}">${esc(m.body)}</li>`
-- : `<li class="sp-msg${m.author === 'visitor' ? ' sp-me' : ''}" data-id="${m.id}"><b>${esc(who(m))}</b> <span class="sp-text">${m.author === 'ai' ? md.render(m.body) : esc(m.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 },
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex 1115b85..be15ac1 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -431,6 +431,33 @@ t('a visitor signed in to OpenVibe: their conversation is theirs, in their Messa
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 () => {
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');