openvibers/openvibe.chat Commits

Support inboxes: translation both ways, and pictures from the team (#152)

OP OpenVibers committed 19 days ago · 9fb3663ab8 · 13 files, +281 −10

* Inbox translation: a visitor's message in the reader's language (kept per message), a reply draft in the visitor's

* Pictures in support conversations: one of the people sends a picture through OpenVibe.Pics as them; the visitor's panel and the inbox show it inline

Changes

ddiff --git a/README.md b/README.md
iindex 38ff6e7..56c0306 100644
---- a/README.md
++++ b/README.md
@@@ -469,6 +469,16 @@ for people and for companies, with "direct live support with an agent", "fully m
waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each
webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks
{ url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.
++- **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.
++- **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
++ (`POST /:id/translate { text, to }`). OpenVibe.AI translates; `CHAT_INBOX_TRANSLATIONS_PER_DAY` (300) new ones an
++ inbox a UTC day, 30 a minute a person.
- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE
/:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or
Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.
ddiff --git a/migrations/0021_inbox_translations.sql b/migrations/0021_inbox_translations.sql
nnew file mode 100644
iindex 0000000..e9f2b72
---- /dev/null
++++ b/migrations/0021_inbox_translations.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Translation for an inbox's people (server/inbox/translate.js): a message's translation into a language is kept, so a
++-- second reader of it pays nothing; translations are counted against the inbox's day like Vibe's answers.
++CREATE TABLE IF NOT EXISTS inbox_translations (
++ message_id bigint NOT NULL,
++ lang text COLLATE "C" NOT NULL,
++ text text NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (message_id, lang)
++);
++ALTER TABLE inbox_ai_usage ADD COLUMN IF NOT EXISTS translations integer NOT NULL DEFAULT 0;
ddiff --git a/public/web/embed.css b/public/web/embed.css
iindex 6f9bf58..c9b5a67 100644
---- a/public/web/embed.css
++++ b/public/web/embed.css
@@@ -67,3 +67,6 @@ a { color: var(--accent); }
.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.re-signin[disabled] { opacity: .65; cursor: default; }
.em-head a.em-x { color: inherit; text-decoration: none; }
++.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
++.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; }
ddiff --git a/public/web/embed.js b/public/web/embed.js
iindex 584d8df..1fb9afa 100644
---- a/public/web/embed.js
++++ b/public/web/embed.js
@@@ -19,6 +19,19 @@
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;
++ // A person's reply may hold OpenVibe.Pics pictures (sent from the inbox): 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) {
++ var box = null, m, n = 0; PICS.lastIndex = 0;
++ while ((m = PICS.exec(String(text || ''))) && n < 4) {
++ 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';
++ a.appendChild(img); box.appendChild(a); n++;
++ }
++ return box;
++ }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not embedded */ } }
@@@ -82,6 +95,7 @@
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 (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }
}
if (m.id) li.dataset.id = String(m.id);
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex 26a4747..ae28616 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -18,6 +18,18 @@
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.
++ 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 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; } }
@@@ -36,7 +48,9 @@
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); li.appendChild(b);
++ b.appendChild(text);
++ var pics = m.author === 'agent' ? picsOf(m.body) : null; if (pics) b.appendChild(pics);
++ li.appendChild(b);
}
li.dataset.id = String(m.id);
return li;
@@@ -47,7 +61,7 @@
if (m.author === 'agent') { var p = thread.querySelector('.vb-me:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
clearTyping();
var stick = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160;
-- thread.appendChild(turn(m));
++ var li = turn(m); thread.appendChild(li); addTranslate(li);
latest = Math.max(latest, Number(m.id));
if (stick) bottom();
}
@@@ -97,6 +111,56 @@
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
}
++ // Translation: a visitor's message into the reader's own language, and the reply draft into the visitor's.
++ var myLang = String((navigator.language || 'en')).toLowerCase();
++ function post(path, body) {
++ return fetch('/api/chat/inboxes/' + cfg.inbox + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
++ }
++ function addTranslate(li) {
++ if (!li || !li.classList.contains('ib-visitor') || !li.dataset.id || li.querySelector('.ib-tr-btn')) return;
++ var body = li.querySelector('.vb-body'); if (!body) return;
++ var b = el('button', 'ib-tr-btn', 'Translate'); b.type = 'button';
++ b.addEventListener('click', function () {
++ b.disabled = true; b.textContent = 'Translating…';
++ post('/conversations/' + cfg.conversation + '/messages/' + li.dataset.id + '/translate', { to: myLang }).then(function (x) {
++ if (!x.ok) { b.disabled = false; b.textContent = (x.d && x.d.error) || 'Try again'; return; }
++ var p = el('p', 'ib-tr', x.d.text); p.lang = x.d.lang; b.replaceWith(p);
++ }).catch(function () { b.disabled = false; b.textContent = 'Try again'; });
++ });
++ body.appendChild(b);
++ }
++ if (thread) Array.prototype.forEach.call(thread.querySelectorAll('.ib-visitor'), addTranslate);
++ var picIn = document.getElementById('ib-pic');
++ if (picIn && input) {
++ picIn.addEventListener('change', function () {
++ var f = picIn.files && picIn.files[0]; picIn.value = '';
++ if (!f) return;
++ var caption = input.value.trim();
++ fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/picture', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type, 'X-Caption': encodeURIComponent(caption) }, body: f })
++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
++ .then(function (x) {
++ if (!x.ok) { window.alert((x.d && x.d.error) || 'That picture did not send.'); return; }
++ input.value = ''; input.dispatchEvent(new Event('input'));
++ appendToThread(x.d.message); touchList(cfg.conversation, x.d.message.body, 'open');
++ }).catch(function () { window.alert('You seem to be offline.'); });
++ });
++ }
++ var trTo = document.getElementById('ib-tr-to');
++ if (trTo && input) {
++ trTo.closest('.ib-tr-pick').hidden = false;
++ trTo.addEventListener('change', function () {
++ var to = trTo.value, text = input.value.trim(); trTo.value = '';
++ if (!to || !text) return;
++ var was = input.value; input.disabled = true;
++ post('/translate', { text: text, to: to }).then(function (x) {
++ input.disabled = false;
++ if (!x.ok) { window.alert((x.d && x.d.error) || 'That did not translate.'); return; }
++ input.value = x.d.text; input.dataset.original = was; input.dispatchEvent(new Event('input')); input.focus();
++ }).catch(function () { input.disabled = false; });
++ });
++ }
++
// Saved replies: "/" at the start of the reply box lists them by shortcut; Enter, Tab or a click puts one in.
var saved = Array.isArray(cfg.saved) ? cfg.saved : [], pick = null, picked = 0;
function closePick() { if (pick) { pick.remove(); pick = null; } input && input.removeAttribute('aria-activedescendant'); }
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex d02c9e1..dbc2d9f 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -218,6 +218,14 @@ body.oc-app-body { overflow: hidden; }
.ib-row .vb-st { margin-left: 4px; }
.ib-rate { font-size: .85rem; line-height: 1; }
.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
++.ib-pic-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: var(--oc-muted); flex: none; font-size: 1.1rem; }
++.ib-pic-btn:hover { color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }
++.ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }
++.ib-tr-btn { all: unset; cursor: pointer; font-size: .78rem; color: var(--oc-muted); margin-top: 2px; border-radius: 6px; padding: 1px 4px; }
++.ib-tr-btn:hover { color: var(--oc-accent); }
++.ib-tr-btn:focus-visible { outline: 2px solid var(--oc-accent); }
++.ib-tr { margin: 4px 0 0; padding-left: 10px; border-left: 2px solid var(--oc-line); color: var(--oc-muted); }
++.ib-tr-pick select { font-size: .8rem; padding: 2px 6px; margin-left: 4px; }
.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }
.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }
.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex fe7e252..47ea9a7 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -107,6 +107,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
++ [null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
['inbox_messages', 'DELETE FROM inbox_messages WHERE conversation_id IN (SELECT c.id FROM inbox_conversations c JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
['inbox_conversations', 'DELETE FROM inbox_conversations WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inbox_members', 'DELETE FROM inbox_members WHERE user_id = ANY(@ids) OR inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
ddiff --git a/server/config.js b/server/config.js
iindex 2876baa..721649b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -76,7 +76,8 @@ module.exports = {
// A verified visitor's token is derived with CHAT_INBOX_IDENTITY_KEY (else Chat's OAuth secret): rotating it moves
// verified visitors to new conversations.
inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',
-- postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)) }, // a visitor's address, widget writes
++ postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)), // a visitor's address, widget writes
++ translationsPerDay: Math.max(0, int(process.env.CHAT_INBOX_TRANSLATIONS_PER_DAY, 300)) }, // new translations an inbox a UTC day
// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex 483b270..6027a72 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -19,6 +19,9 @@
* POST /:id/members { username } (owner) DELETE /:id/members/:userId (owner)
* POST /:id/identity { action: on | rotate | off } (owner) → { identity_secret }: verified visitors
* GET /:id/replies { replies }: saved replies POST /:id/replies { shortcut, body } DELETE /:id/replies/:replyId
++ * POST /:id/conversations/:cid/messages/:mid/translate { to } → { text, lang, cached }: a message in the reader's language
++ * POST /:id/translate { text, to } → { text, lang }: a reply draft in the visitor's language
++ * POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* GET /:id/conversations ?status=active|bot|waiting|open|resolved
@@@ -35,9 +38,10 @@ const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const inboxes = require('./inbox');
const webhooks = require('./webhooks');
++const translate = require('./translate');
function send(res, err) {
-- if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError) return res.status(err.status).json({ error: err.message, code: err.code });
++ if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError || err instanceof translate.TranslateError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Inbox]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
}
@@@ -144,6 +148,32 @@ agents.delete('/:id/replies/:replyId', requireAuth, manageLimit, async (req, res
const inbox = await agentInbox(req, res); if (!inbox) return;
try { res.json({ replies: await inboxes.deleteReply(inbox, req.user, req.params.replyId) }); } catch (err) { send(res, err); }
});
++// A picture from one of the inbox's people: it goes to OpenVibe.Pics as them (their own sign-in, like a room's picture),
++// and the conversation gets a message with its link, which the visitor's panel shows inline.
++const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
++const pictureLimit = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
++agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req, res, next) => {
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Send pictures with your own sign-in', code: 'inbox.person_only' });
++ 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 conv = await agentConversation(req, res); if (!conv) return;
++ 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 { 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); }
++});
++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;
++ try { noStore(res).json(await translate.message(req.inbox, conv, req.params.mid, req.body && req.body.to)); } catch (err) { send(res, err); }
++});
++agents.post('/:id/translate', requireAuth, translateLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res); if (!inbox) return;
++ try { noStore(res).json(await translate.draft(inbox, req.body && req.body.text, req.body && req.body.to)); } catch (err) { send(res, err); }
++});
agents.get('/:id/webhooks', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
noStore(res).json({ webhooks: await webhooks.list(inbox) });
ddiff --git a/server/inbox/translate.js b/server/inbox/translate.js
nnew file mode 100644
iindex 0000000..164e6ab
---- /dev/null
++++ b/server/inbox/translate.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * Translation for an inbox's people (server/inbox/inbox.js): a visitor's message in their reader's language, on request,
++ * and a reply draft in the visitor's. OpenVibe.AI translates (chat.member.turn, Chat's token); each message's
++ * translation into a language is kept (inbox_translations), so a second reader pays nothing. At most
++ * CHAT_INBOX_TRANSLATIONS_PER_DAY (300) new translations an inbox a UTC day.
++ */
++const db = require('../db/database');
++const config = require('../config');
++
++const LANG_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
++const NAMES = { en: 'English', es: 'Spanish', pt: 'Portuguese', fr: 'French', de: 'German', it: 'Italian', nl: 'Dutch', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish', tr: 'Turkish', ar: 'Arabic', hi: 'Hindi', id: 'Indonesian', vi: 'Vietnamese', th: 'Thai', sv: 'Swedish' };
++const today = () => new Date().toISOString().slice(0, 10);
++
++class TranslateError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const fail = (status, code, message) => { throw new TranslateError(status, code, message); };
++
++function langOf(to) {
++ const l = String(to || '').trim();
++ if (!LANG_RE.test(l)) fail(422, 'inbox.lang', 'A language is a code like en or pt-BR');
++ return l.toLowerCase();
++}
++const nameOf = (l) => NAMES[l.split('-')[0]] || l;
++
++async function spend(inboxId) {
++ const used = Number((await db.get('SELECT translations FROM inbox_ai_usage WHERE inbox_id = ? AND day = ?', [inboxId, today()]) || {}).translations) || 0;
++ if (used >= config.inbox.translationsPerDay) fail(429, 'inbox.translations', 'This inbox has translated a lot today; try again tomorrow');
++ await db.run(`INSERT INTO inbox_ai_usage (inbox_id, day, replies, translations) VALUES (?, ?, 0, 1)
++ ON CONFLICT (inbox_id, day) DO UPDATE SET translations = inbox_ai_usage.translations + 1`, [inboxId, today()]);
++}
++
++async function ask(inbox, text, to, deps = {}) {
++ const aiClient = deps.aiClient || require('../ai/client');
++ const out = await aiClient.structured('chat.member.turn', {
++ messages: [
++ { role: 'system', content: `Translate the user's message into ${nameOf(to)}. Answer with the translation only: no quotes, no notes. Keep names, numbers, links and order numbers as they are. If it is already in ${nameOf(to)}, answer it unchanged.` },
++ { role: 'user', content: String(text).slice(0, 4000) },
++ ],
++ max_tokens: 900, temperature: 0,
++ }, { force: true, attribution: { service: 'chat', type: 'inbox-translation', id: inbox.id, label: inbox.name.slice(0, 200) } });
++ const msg = out && Array.isArray(out.choices) && out.choices[0] && out.choices[0].message;
++ const t = msg && typeof msg.content === 'string' ? msg.content.trim() : '';
++ if (!t) fail(503, 'inbox.translate_unavailable', 'Translation is not available right now');
++ return t.slice(0, 6000);
++}
++
++/** A message of the conversation in `to`: { text, cached }. */
++async function message(inbox, conv, messageId, to, deps = {}) {
++ const lang = langOf(to);
++ const m = await db.get('SELECT id, body FROM inbox_messages WHERE id = ? AND conversation_id = ?', [Number(messageId) || 0, conv.id]);
++ if (!m) fail(404, 'inbox.no_message', 'No such message');
++ const kept = await db.get('SELECT text FROM inbox_translations WHERE message_id = ? AND lang = ?', [m.id, lang]);
++ if (kept) return { text: kept.text, lang, cached: true };
++ await spend(inbox.id);
++ const text = await ask(inbox, m.body, lang, deps);
++ await db.run('INSERT INTO inbox_translations (message_id, lang, text) VALUES (?, ?, ?) ON CONFLICT (message_id, lang) DO NOTHING', [m.id, lang, text]);
++ return { text, lang, cached: false };
++}
++
++/** A reply draft in `to` (not kept): { text }. */
++async function draft(inbox, text, to, deps = {}) {
++ const lang = langOf(to);
++ const body = String(text || '').trim().slice(0, 4000);
++ if (!body) fail(422, 'inbox.empty', 'Write the reply first');
++ await spend(inbox.id);
++ return { text: await ask(inbox, body, lang, deps), lang };
++}
++
++module.exports = { TranslateError, message, draft, langOf };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex aef94a3..3700045 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -16,10 +16,12 @@ const path = require('path');
const md = require('../../public/web/vibe-md.js');
const inboxes = require('../inbox/inbox');
const webhooks = require('../inbox/webhooks');
++const pictures = require('../rooms/pictures');
const { embedOf } = require('../inbox/routes');
const { esc, asset } = require('./layout');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
++const TR_LANGS = [['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'], ['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['tr', 'Turkish'], ['ru', 'Russian'], ['uk', 'Ukrainian'], ['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['vi', 'Vietnamese'], ['th', 'Thai'], ['ja', 'Japanese'], ['ko', 'Korean'], ['zh', 'Chinese'], ['sv', 'Swedish']];
const STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };
const json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
const ago = (ts) => {
@@@ -42,7 +44,7 @@ function embedHtml(inbox) {
<script src="${asset('embed.js')}" defer></script>
</head>
<body style="--accent:${esc(inbox.color)}">
--<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}">
++<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}" data-pics="${esc(pictures.publicUrl())}">
<header class="em-head"><span class="em-mark" aria-hidden="true">${esc(inbox.name.slice(0, 1).toUpperCase())}</span>
<div><b>${esc(inbox.name)}</b><span id="em-state">${!Number(inbox.ai_enabled) ? 'We usually reply within a few hours.' : inboxes.peopleAnswer(inbox) ? 'Vibe answers right away. Ask for a person any time.' : 'Vibe, the assistant, answers right away.'}</span></div>
<button class="em-x" type="button" data-close aria-label="Close the chat">&times;</button></header>
@@@ -130,7 +132,7 @@ ${list}
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>')}</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>`;
};
const tabs = [['active', 'Active'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
const actions = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
@@@ -154,7 +156,7 @@ ${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your s
</div>
${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>
--<button class="vb-send" type="submit" aria-label="Send">&#8593;</button></form><p class="vb-fine">Replying takes the conversation from Vibe.</p></div>` : ''}
++${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden><span aria-hidden="true">&#128206;</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">&#8593;</button></form><p class="vb-fine">Replying takes the conversation from Vibe. <label class="ib-tr-pick" hidden>Translate my reply into <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label></p></div>` : ''}
</section>
</div>`;
await page(req, res, 200, {
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex acc642d..6c003dd 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -26,8 +26,23 @@ async function until(fn, label) {
}
const visitorMessages = async () => (await widget('GET', `/messages?token=${token}`)).body.messages;
++// A stand-in OpenVibe.Pics that takes any picture and answers its links.
++let picsServer = null;
++function startPics() {
++ let n = 0;
++ picsServer = require('http').createServer((req, res) => {
++ req.resume();
++ req.on('end', () => {
++ const slug = `Pk${String(++n).padStart(6, '2')}`.replace(/[01]/g, '3');
++ res.writeHead(201, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ slug, links: { direct: `https://openvibe.pics/i/${slug}.png`, page: `https://openvibe.pics/i/${slug}` } }));
++ });
++ });
++ return new Promise((r) => picsServer.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${picsServer.address().port}`)));
++}
++
t('boot', async () => {
-- h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500' } });
++ h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500', CHAT_PICS_INTERNAL_URL: await startPics() } });
aiClient = require('../server/ai/client');
inboxes = require('../server/inbox/inbox');
ann = h.addUser('ann', { subject: ids.newId('user') });
@@@ -270,6 +285,49 @@ t('webhooks: signed JSON for every event, a line for Slack when a visitor waits;
} finally { webhooks._setSend(null); }
});
++t('translation: a visitor\'s message into the reader\'s language (kept for the next reader), a reply draft into theirs', async () => {
++ reply = 'Hola, ¿en qué te ayudo?';
++ const v = (await widget('POST', '/visit', {})).body.token;
++ await widget('POST', '/messages', { token: v, message: 'Mi lámpara parpadea' });
++ const seen = await until(async () => { const m = (await widget('GET', `/messages?token=${v}`)).body; return m.messages.some((x) => x.author === 'ai') && m; }, 'Vibe answers');
++ const cid = seen.conversation.id;
++ const mid = seen.messages.find((x) => x.author === 'visitor').id;
++ reply = 'My lamp flickers';
++ turns.length = 0;
++ const r = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'en' });
++ assert.deepStrictEqual([r.status, r.body.text, r.body.cached], [200, 'My lamp flickers', false]);
++ assert.ok(turns[0].input.messages[0].content.includes('into English'));
++ const again = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, ann, { to: 'en' });
++ assert.ok(again.body.cached && turns.length === 1, 'the next reader pays nothing');
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'not a language' })).status, 422);
++ const outsider = h.addUser('eve', { subject: ids.newId('user') });
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, outsider, { to: 'en' })).status, 404);
++ reply = 'Hola, te mando una nueva hoy.';
++ const d = await agentsApi('POST', `/${inbox.id}/translate`, bob, { text: 'Hi, I will send you a new one today.', to: 'es' });
++ assert.deepStrictEqual([d.status, d.body.text, d.body.lang], [200, 'Hola, te mando una nueva hoy.', 'es']);
++ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
++ assert.ok(app.includes('id="ib-tr-to"') && app.includes('Translate my reply into'));
++});
++
++t('pictures: one of the people sends a picture (through OpenVibe.Pics as them); the visitor and the people see it', async () => {
++ const v = (await widget('POST', '/visit', {})).body.token;
++ const cid = (await widget('POST', '/messages', { token: v, message: 'Here is the box it came in' })).body.conversation.id;
++ const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
++ const send = (u, headers) => fetch(`${h.base}/api/chat/inboxes/${inbox.id}/conversations/${cid}/picture`, { method: 'POST', headers: { authorization: `Bearer ${u.token}`, 'content-type': 'image/png', ...headers }, body: PNG })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++ const r = await send(bob, { 'x-caption': encodeURIComponent('Like this one?') });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ assert.ok(/^Like this one\?\nhttps:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/.test(r.body.message.body), r.body.message.body);
++ assert.strictEqual(r.body.message.author, 'agent');
++ assert.ok((await widget('GET', `/messages?token=${v}`)).body.messages.some((m) => m.id === r.body.message.id), 'the visitor gets it');
++ const outsider = h.addUser('fay', { subject: ids.newId('user') });
++ assert.strictEqual((await send(outsider)).status, 404);
++ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
++ assert.ok(app.includes('class="oc-pic"') && app.includes('/thumb.webp') && app.includes('id="ib-pic"'), 'the people see it inline, and the reply box has a picture button');
++ const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text());
++ assert.ok(panel.includes('data-pics="https://openvibe.pics"'), 'the visitor\'s panel knows where pictures come from');
++});
++
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);
@@@ -302,4 +360,4 @@ t('the people\'s pages: the list, a conversation and the settings with the snipp
assert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);
});
--t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); if (picsServer) picsServer.close(); });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 9ae8d0f..adc97be 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [