openvibers/openvibe.chat Commits
Support inboxes: who answers (assign to anyone of the people, a Mine tab) and taking turns (a waiting visitor goes to the next person, only they are told) (#155)
Changes
@@@ -461,6 +461,10 @@ for people and for companies, with "direct live support with an agent", "fully m/api/chat/support/:cid/messages`). The account comes only from the request's credentials. Asite's verified identity, when there is one, still decides the token. Export lists these conversations; deletionleaves them with the inbox's team, without the person.++- **Who answers** (`server/inbox/routing.js`): replying takes a conversation, and any of the inbox's people can assign++ it to another ("Answered by", `PATCH /api/chat/inboxes/:id/conversations/:cid { assignee_id }`). With **take turns**++ on (`route: round_robin`), a visitor who starts waiting goes to the person assigned longest ago, and only they are++ notified. The list has a **Mine** tab (`?status=mine`) and shows who has each conversation.- **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null foralways): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff saysso, and Vibe tells visitors a person replies when the team is back. Notifications still go out.nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Who answers a support conversation (server/inbox/routing.js): with turns on, a conversation that starts waiting goes++-- to the person whose turn it is (the one assigned longest ago); a teammate can assign one to anyone in the inbox.++ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS route text COLLATE "C" NOT NULL DEFAULT 'none' CHECK (route IN ('none', 'round_robin'));++ALTER TABLE inbox_members ADD COLUMN IF NOT EXISTS last_assigned_at text COLLATE "C";@@@ -97,6 +97,10 @@else if (m.type === 'inbox_message') {touchList(m.conversation, m.message.body, null);if (m.conversation === cfg.conversation) appendToThread(m.message);++ } else if (m.type === 'inbox_assigned') {++ var row2 = listItem(m.conversation); var prev = row2 && row2.querySelector('.ib-prev');++ if (prev) { var who = prev.querySelector('.ib-who-has'); if (who) who.textContent = m.assignee === cfg.me ? 'You' : 'Someone'; }++ if (m.conversation === cfg.conversation) { var sel = document.querySelector('.ib-assign select'); if (sel) sel.value = m.assignee == null ? '' : String(m.assignee); }} else if (m.type === 'inbox_rating') {var item = listItem(m.conversation), row = item && item.querySelector('.ib-row');if (row) { var old = row.querySelector('.ib-rate'); if (old) old.remove(); var r = el('span', 'ib-rate', m.rating > 0 ? '👍' : '👎'); if (m.comment) r.title = m.comment; row.insertBefore(r, row.querySelector('time')); }@@@ -214,6 +218,9 @@var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');}++ // "Answered by": choosing a person assigns at once.++ var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');++ if (assignSel) assignSel.addEventListener('change', function () { assignSel.form.requestSubmit ? assignSel.form.requestSubmit() : assignSel.form.submit(); });app.addEventListener('click', function (e) { if (e.target.closest('[data-vb-open]')) setRail(true); else if (e.target.closest('[data-vb-close]')) setRail(false); });bottom();connect();@@@ -218,6 +218,9 @@ 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-assign { display: inline-flex; align-items: center; margin-right: 8px; font-size: .85rem; color: var(--oc-muted); }++.ib-assign select { margin-left: 6px; font-size: .85rem; padding: 4px 8px; }++.ib-who-has { color: var(--oc-accent); font-weight: 600; }.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); }@@@ -72,6 +72,10 @@ function validate(input = {}, current = null) {if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);++ if (input.route !== undefined) {++ if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');++ out.route = String(input.route);++ }if (input.ai_instructions !== undefined) out.ai_instructions = clean(input.ai_instructions, LIMITS.instructions);const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);if (input.knowledge_urls !== undefined) {@@@ -126,8 +130,8 @@ async function update(inbox, user, input = {}) {const v = validate(input, inbox);const next = { ...inbox, ...v };const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));-- await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ? WHERE id = ?`,-- [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), inbox.id]);++ await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ? WHERE id = ?`,++ [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', inbox.id]);return await get(inbox.id);}@@@ -150,7 +154,7 @@ async function mine(user) {function shape(r) {return {id: r.id, name: r.name, greeting: r.greeting, color: r.color, ai_enabled: !!Number(r.ai_enabled), handoff: peopleAnswer(r), ai_instructions: r.ai_instructions,-- verified_visitors: !!r.identity_secret, hours: hoursOf(r),++ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none',knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,};}@@@ -204,11 +208,27 @@ async function addMessage(conv, { author, body, userId = null, name = null }, dereturn message;}++/** Turns: a waiting conversation nobody has yet goes to the person whose turn it is (they are then the one told). */++async function takeTurn(conv, deps = {}) {++ const fresh = await db.get('SELECT assignee_id FROM inbox_conversations WHERE id = ?', [conv.id]);++ if (!fresh || fresh.assignee_id != null) return;++ const routing = require('./routing');++ const next = await routing.nextInTurn(await get(conv.inbox_id) || {});++ if (next == null) return;++ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ? AND assignee_id IS NULL', [next, conv.id]);++ await routing.noteAssigned(conv.inbox_id, next);++ broadcaster(deps)(conv.inbox_id, null, { type: 'inbox_assigned', inbox: conv.inbox_id, conversation: conv.id, assignee: next });++}++async function setStatusRow(conv, status, deps = {}, extra = {}) {await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,extra.assignee ? [status, extra.assignee, conv.id] : [status, conv.id]);broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_status', inbox: conv.inbox_id, conversation: conv.id, status });-- if (status === 'waiting' && conv.status !== 'waiting') { require('./notify').later(conv, 'waiting'); require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id }); }++ if (status === 'waiting' && conv.status !== 'waiting') {++ await takeTurn(conv, deps);++ require('./notify').later(conv, 'waiting');++ require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id });++ }if (status === 'resolved' && conv.status !== 'resolved') require('./webhooks').later(conv.inbox_id, 'conversation.resolved', { conversationId: conv.id });return { ...conv, status };}@@@ -341,6 +361,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = nullconst filtered = wordFilter.check(body);const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, 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');if (conv.status === 'bot') {const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));@@@ -410,9 +431,10 @@ async function accountPost(account, conv, text, deps = {}) {// ── Agents ─────────────────────────────────────────────────────────────────────────────────────────────────────────--async function conversations(inbox, { status = null, limit = 100 } = {}) {-- const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : STATUSES.includes(status) ? 'AND c.status = ?' : '';-- const params = [inbox.id, ...(STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];++async function conversations(inbox, { status = null, limit = 100, user = null } = {}) {++ const mine = status === 'mine' && user;++ const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : mine ? "AND c.status IN ('waiting', 'open') AND c.assignee_id = ?" : STATUSES.includes(status) ? 'AND c.status = ?' : '';++ const params = [inbox.id, ...(mine ? [user.id] : STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id AND m.author <> 'system' ORDER BY m.id DESC LIMIT 1) AS last_bodyFROM inbox_conversations c WHERE c.inbox_id = ? ${where}ORDER BY CASE c.status WHEN 'waiting' THEN 0 WHEN 'open' THEN 1 WHEN 'bot' THEN 2 ELSE 3 END, COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, params);@@@ -443,6 +465,17 @@ async function setStatus(inbox, conv, user, status, deps = {}) {return convShape(next);}++/** One of the inbox's people (or nobody: null) answers this conversation; any of them may assign it. */++async function assign(inbox, conv, user, assigneeId, deps = {}) {++ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people assign conversations');++ const id = assigneeId == null || assigneeId === '' ? null : Number(assigneeId);++ if (id != null && !(await db.get('SELECT 1 FROM inbox_members WHERE inbox_id = ? AND user_id = ?', [inbox.id, id]))) fail(422, 'inbox.assignee', 'Assign it to one of the inbox\'s people');++ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ?', [id, conv.id]);++ if (id != null) await require('./routing').noteAssigned(inbox.id, id);++ broadcaster(deps)(inbox.id, null, { type: 'inbox_assigned', inbox: inbox.id, conversation: conv.id, assignee: id });++ return convShape(await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conv.id]));++}++async function markRead(inbox, conv) { await db.run('UPDATE inbox_conversations SET agent_unread = 0 WHERE id = ?', [conv.id]); }// ── Vibe, the first responder ─────────────────────────────────────────────────────────────────────────────────────@@@ -543,6 +576,6 @@ function _reset() { inFlight.clear(); }module.exports = {InboxError, INBOX_RE, CONV_RE, LIMITS, MAX_INBOXES, AI_PER_CONVERSATION, hashToken,create, update, remove, get, access, mine, members, addMember, removeMember, shape, publicConfig, plainText, peopleAnswer,-- visit, visitorPost, handoff, rate, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,++ visit, visitorPost, handoff, rate, assign, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,aiAnswer, inboxPrompt, _reset,};@@@ -29,10 +29,10 @@* 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++ * GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved* GET /:id/conversations/:cid/messages ?after=* POST /:id/conversations/:cid/messages { message } → 201: a person's answer (the conversation becomes open)-- * PATCH /:id/conversations/:cid { status: open | resolved | bot }++ * PATCH /:id/conversations/:cid { status: open | resolved | bot, assignee_id: a person's id | null }* POST /:id/conversations/:cid/read*/const express = require('express');@@@ -219,7 +219,7 @@ agents.delete('/:id/members/:userId', requireAuth, manageLimit, async (req, res)});agents.get('/:id/conversations', requireAuth, async (req, res) => {const inbox = await agentInbox(req, res); if (!inbox) return;-- noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit }) });++ noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit, user: req.user }) });});agents.get('/:id/conversations/:cid/messages', requireAuth, async (req, res) => {const conv = await agentConversation(req, res); if (!conv) return;@@@ -231,7 +231,14 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (});agents.patch('/:id/conversations/:cid', requireAuth, manageLimit, async (req, res) => {const conv = await agentConversation(req, res); if (!conv) return;-- try { res.json({ conversation: await inboxes.setStatus(req.inbox, conv, req.user, req.body && req.body.status) }); } catch (err) { send(res, err); }++ const b = req.body || {};++ try {++ let out = null;++ if (Object.prototype.hasOwnProperty.call(b, 'assignee_id')) out = await inboxes.assign(req.inbox, conv, req.user, b.assignee_id);++ if (b.status !== undefined) out = await inboxes.setStatus(req.inbox, { ...conv, assignee_id: out ? out.assignee_id : conv.assignee_id }, req.user, b.status);++ if (!out) return res.status(422).json({ error: 'Send a status or an assignee_id', code: 'inbox.nothing' });++ res.json({ conversation: out });++ } catch (err) { send(res, err); }});agents.post('/:id/conversations/:cid/read', requireAuth, async (req, res) => {const conv = await agentConversation(req, res); if (!conv) return;nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++/**++ * Who answers a support conversation (server/inbox/inbox.js): one of the inbox's people takes it by answering, is++ * assigned by a teammate, or, with turns on (inboxes.route = 'round_robin'), a conversation that starts waiting goes++ * to the person whose turn it is: the one assigned longest ago (inbox_members.last_assigned_at). Notifications then go++ * to that person only (server/inbox/notify.js already tells the assignee first).++ */++const db = require('../db/database');++++/** The person whose turn it is, or null (turns off, or nobody in the inbox). */++async function nextInTurn(inbox) {++ if (inbox.route !== 'round_robin') return null;++ const row = await db.get(`SELECT user_id FROM inbox_members WHERE inbox_id = ?++ ORDER BY last_assigned_at IS NOT NULL, last_assigned_at, role DESC, user_id LIMIT 1`, [inbox.id]);++ return row ? Number(row.user_id) : null;++}++++async function noteAssigned(inboxId, userId) {++ if (userId == null) return;++ await db.run('UPDATE inbox_members SET last_assigned_at = ov_now_iso() WHERE inbox_id = ? AND user_id = ?', [inboxId, userId]);++}++++module.exports = { nextInTurn, noteAssigned };@@@ -120,22 +120,25 @@ ${list}router.get('/inbox/:id', async (req, res) => {const a = await agentInbox(req, res); if (!a) return;const { actor, inbox } = a;-- const filter = ['active', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';-- const list = await inboxes.conversations(inbox, { status: filter });++ const filter = ['active', 'mine', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';++ const list = await inboxes.conversations(inbox, { status: filter, user: actor.user });++ const team = await inboxes.members(inbox);++ const nameOf = (id) => { const m = team.find((x) => x.user_id === Number(id)); return m ? (m.display_name || m.username) : null; };const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;const saved = await inboxes.savedReplies(inbox);const msgs = current ? await inboxes.messages(current) : [];if (current) await inboxes.markRead(inbox, current);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">${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>`;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>`;};-- 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">++ 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 actions = current ? `${assignForm}<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">${current.status !== 'resolved' ? '<button name="status" value="resolved">Resolve</button>' : '<button name="status" value="open">Reopen</button>'}${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>` : '';const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">@@@ -172,6 +175,12 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><inputtry { await inboxes.agentPost(a.inbox, conv, a.actor.user, req.body && req.body.message); } catch { /* the page shows the thread as it is */ }res.redirect(303, `/inbox/${a.inbox.id}?c=${conv.id}`);});++ router.post('/inbox/:id/c/:cid/assign', formLimit, sameSite, async (req, res) => {++ const a = await agentInbox(req, res); if (!a) return;++ const conv = await inboxes.conversation(a.inbox, req.params.cid);++ if (conv) { try { await inboxes.assign(a.inbox, conv, a.actor.user, req.body && req.body.assignee); } catch { /* unchanged */ } }++ res.redirect(303, `/inbox/${a.inbox.id}${conv ? `?c=${conv.id}` : ''}`);++ });router.post('/inbox/:id/c/:cid/status', formLimit, sameSite, async (req, res) => {const a = await agentInbox(req, res); if (!a) return;const conv = await inboxes.conversation(a.inbox, req.params.cid);@@@ -222,6 +231,7 @@ ${hooks.length < webhooks.MAX_WEBHOOKS ? `<form class="oc-form" method="post" ac<label>Greeting <input name="greeting" maxlength="300" value="${esc(shape.greeting)}"></label><label>Colour <input name="color" type="color" value="${esc(shape.color)}"></label><label class="oc-check"><input type="checkbox" name="ai_enabled" value="1"${shape.ai_enabled ? ' checked' : ''}> Vibe answers first</label>++<label class="oc-check"><input type="checkbox" name="route" value="round_robin"${shape.route === 'round_robin' ? ' checked' : ''}> Take turns: a visitor waiting for a person goes to the next of your people, and only they are told</label><label class="oc-check"><input type="checkbox" name="handoff" value="1"${shape.handoff ? ' checked' : ''}> People answer here too (visitors can ask for a person; off: a site assistant where only Vibe answers)</label><fieldset class="oc-fieldset"><legend>Hours your people answer</legend><label class="oc-check"><input type="checkbox" name="hours_on" value="1"${shape.hours ? ' checked' : ''}> Set hours: outside them, visitors see when you are back, and Vibe tells them</label>@@@ -250,7 +260,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.shoconst b = req.body || {};try {const hours = b.hours_on === '1' ? { tz: b.hours_tz, days: [].concat(b.hours_days || []), start: b.hours_start, end: b.hours_end } : null;-- await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });++ await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', route: b.route === 'round_robin' ? 'round_robin' : 'none', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }});@@@ -386,6 +386,28 @@ t('business hours: outside them the panel says when the team is back, a handoffassert.strictEqual((await widget('GET', '')).body.inbox.away, null, 'no hours: always open');});++t('routing: taking turns hands each waiting visitor to the next person; anyone of the people assigns; Mine lists theirs', async () => {++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'random' })).status, 422);++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'round_robin' })).status, 200);++ await h.db.run('UPDATE inbox_members SET last_assigned_at = NULL WHERE inbox_id = ?', [inbox.id]);++ const waiting = async (msg) => {++ const v = (await widget('POST', '/visit', {})).body.token;++ const cid = (await widget('POST', '/messages', { token: v, message: msg })).body.conversation.id;++ await widget('POST', '/handoff', { token: v });++ return (await h.db.get('SELECT id, assignee_id FROM inbox_conversations WHERE id = ?', [cid]));++ };++ const one = await waiting('First in line'), two = await waiting('Second in line'), three = await waiting('Third in line');++ assert.deepStrictEqual([one, two, three].map((c) => Number(c.assignee_id)), [ann.id, bob.id, ann.id], 'turns go round the people');++ const mine = (await agentsApi('GET', `/${inbox.id}/conversations?status=mine`, bob)).body.conversations.map((c) => c.id);++ assert.ok(mine.includes(two.id) && !mine.includes(one.id), 'Mine is what was handed to them');++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: bob.id })).body.conversation.assignee_id, bob.id, 'one of the people takes it');++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: 424242 })).status, 422, 'only to one of the people');++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, ann, { assignee_id: null })).body.conversation.assignee_id, null);++ const app = await fetch(`${h.base}/inbox/${inbox.id}?f=mine&c=${two.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());++ assert.ok(app.includes('Answered by') && app.includes(`data-conv="${two.id}"`) && app.includes('aria-current="page">Mine</a>'));++ await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'none' });++});++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);
@@ -461,6 +461,10 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -461,6 +461,10 @@ for people and for companies, with "direct live support with an agent", "fully m
/api/chat/support/:cid/messages`). The account comes only from the request's credentials. A
/api/chat/support/:cid/messages`). The account comes only from the request's credentials. A
site's verified identity, when there is one, still decides the token. Export lists these conversations; deletion
site's verified identity, when there is one, still decides the token. Export lists these conversations; deletion
leaves them with the inbox's team, without the person.
leaves them with the inbox's team, without the person.
+- **Who answers** (`server/inbox/routing.js`): replying takes a conversation, and any of the inbox's people can assign
+ it to another ("Answered by", `PATCH /api/chat/inboxes/:id/conversations/:cid { assignee_id }`). With **take turns**
+ on (`route: round_robin`), a visitor who starts waiting goes to the person assigned longest ago, and only they are
+ notified. The list has a **Mine** tab (`?status=mine`) and shows who has each conversation.
- **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null for
- **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null for
always): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff says
always): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff says
so, and Vibe tells visitors a person replies when the team is back. Notifications still go out.
so, and Vibe tells visitors a person replies when the team is back. Notifications still go out.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Who answers a support conversation (server/inbox/routing.js): with turns on, a conversation that starts waiting goes
+-- to the person whose turn it is (the one assigned longest ago); a teammate can assign one to anyone in the inbox.
+ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS route text COLLATE "C" NOT NULL DEFAULT 'none' CHECK (route IN ('none', 'round_robin'));
+ALTER TABLE inbox_members ADD COLUMN IF NOT EXISTS last_assigned_at text COLLATE "C";
@@ -97,6 +97,10 @@
@@ -97,6 +97,10 @@
else if (m.type === 'inbox_message') {
else if (m.type === 'inbox_message') {
touchList(m.conversation, m.message.body, null);
touchList(m.conversation, m.message.body, null);
if (m.conversation === cfg.conversation) appendToThread(m.message);
if (m.conversation === cfg.conversation) appendToThread(m.message);
+ } else if (m.type === 'inbox_assigned') {
+ var row2 = listItem(m.conversation); var prev = row2 && row2.querySelector('.ib-prev');
+ if (prev) { var who = prev.querySelector('.ib-who-has'); if (who) who.textContent = m.assignee === cfg.me ? 'You' : 'Someone'; }
+ if (m.conversation === cfg.conversation) { var sel = document.querySelector('.ib-assign select'); if (sel) sel.value = m.assignee == null ? '' : String(m.assignee); }
} else if (m.type === 'inbox_rating') {
} else if (m.type === 'inbox_rating') {
var item = listItem(m.conversation), row = item && item.querySelector('.ib-row');
var item = listItem(m.conversation), row = item && item.querySelector('.ib-row');
if (row) { var old = row.querySelector('.ib-rate'); if (old) old.remove(); var r = el('span', 'ib-rate', m.rating > 0 ? '👍' : '👎'); if (m.comment) r.title = m.comment; row.insertBefore(r, row.querySelector('time')); }
if (row) { var old = row.querySelector('.ib-rate'); if (old) old.remove(); var r = el('span', 'ib-rate', m.rating > 0 ? '👍' : '👎'); if (m.comment) r.title = m.comment; row.insertBefore(r, row.querySelector('time')); }
@@ -214,6 +218,9 @@
@@ -214,6 +218,9 @@
var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;
var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;
var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
}
+ // "Answered by": choosing a person assigns at once.
+ var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');
+ if (assignSel) assignSel.addEventListener('change', function () { assignSel.form.requestSubmit ? assignSel.form.requestSubmit() : assignSel.form.submit(); });
app.addEventListener('click', function (e) { if (e.target.closest('[data-vb-open]')) setRail(true); else if (e.target.closest('[data-vb-close]')) setRail(false); });
app.addEventListener('click', function (e) { if (e.target.closest('[data-vb-open]')) setRail(true); else if (e.target.closest('[data-vb-close]')) setRail(false); });
bottom();
bottom();
connect();
connect();
@@ -218,6 +218,9 @@ body.oc-app-body { overflow: hidden; }
@@ -218,6 +218,9 @@ body.oc-app-body { overflow: hidden; }
.ib-row .vb-st { margin-left: 4px; }
.ib-row .vb-st { margin-left: 4px; }
.ib-rate { font-size: .85rem; line-height: 1; }
.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-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
+.ib-assign { display: inline-flex; align-items: center; margin-right: 8px; font-size: .85rem; color: var(--oc-muted); }
+.ib-assign select { margin-left: 6px; font-size: .85rem; padding: 4px 8px; }
+.ib-who-has { color: var(--oc-accent); font-weight: 600; }
.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 { 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: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-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }
@@ -72,6 +72,10 @@ function validate(input = {}, current = null) {
@@ -72,6 +72,10 @@ function validate(input = {}, current = null) {
if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;
if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;
if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;
if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;
if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);
if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);
+ if (input.route !== undefined) {
+ if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');
+ out.route = String(input.route);
+ }
if (input.ai_instructions !== undefined) out.ai_instructions = clean(input.ai_instructions, LIMITS.instructions);
if (input.ai_instructions !== undefined) out.ai_instructions = clean(input.ai_instructions, LIMITS.instructions);
const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
if (input.knowledge_urls !== undefined) {
if (input.knowledge_urls !== undefined) {
@@ -126,8 +130,8 @@ async function update(inbox, user, input = {}) {
@@ -126,8 +130,8 @@ async function update(inbox, user, input = {}) {
const v = validate(input, inbox);
const v = validate(input, inbox);
const next = { ...inbox, ...v };
const next = { ...inbox, ...v };
const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));
const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));
- await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ? WHERE id = ?`,
- [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), inbox.id]);
+ await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ? WHERE id = ?`,
+ [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', inbox.id]);
return await get(inbox.id);
return await get(inbox.id);
}
}
@@ -150,7 +154,7 @@ async function mine(user) {
@@ -150,7 +154,7 @@ async function mine(user) {
function shape(r) {
function shape(r) {
return {
return {
id: r.id, name: r.name, greeting: r.greeting, color: r.color, ai_enabled: !!Number(r.ai_enabled), handoff: peopleAnswer(r), ai_instructions: r.ai_instructions,
id: r.id, name: r.name, greeting: r.greeting, color: r.color, ai_enabled: !!Number(r.ai_enabled), handoff: peopleAnswer(r), ai_instructions: r.ai_instructions,
- verified_visitors: !!r.identity_secret, hours: hoursOf(r),
+ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none',
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
};
};
}
}
@@ -204,11 +208,27 @@ async function addMessage(conv, { author, body, userId = null, name = null }, de
@@ -204,11 +208,27 @@ async function addMessage(conv, { author, body, userId = null, name = null }, de
return message;
return message;
}
}
+/** Turns: a waiting conversation nobody has yet goes to the person whose turn it is (they are then the one told). */
+async function takeTurn(conv, deps = {}) {
+ const fresh = await db.get('SELECT assignee_id FROM inbox_conversations WHERE id = ?', [conv.id]);
+ if (!fresh || fresh.assignee_id != null) return;
+ const routing = require('./routing');
+ const next = await routing.nextInTurn(await get(conv.inbox_id) || {});
+ if (next == null) return;
+ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ? AND assignee_id IS NULL', [next, conv.id]);
+ await routing.noteAssigned(conv.inbox_id, next);
+ broadcaster(deps)(conv.inbox_id, null, { type: 'inbox_assigned', inbox: conv.inbox_id, conversation: conv.id, assignee: next });
+}
+
async function setStatusRow(conv, status, deps = {}, extra = {}) {
async function setStatusRow(conv, status, deps = {}, extra = {}) {
await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,
await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,
extra.assignee ? [status, extra.assignee, conv.id] : [status, conv.id]);
extra.assignee ? [status, extra.assignee, conv.id] : [status, conv.id]);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_status', inbox: conv.inbox_id, conversation: conv.id, status });
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_status', inbox: conv.inbox_id, conversation: conv.id, status });
- if (status === 'waiting' && conv.status !== 'waiting') { require('./notify').later(conv, 'waiting'); require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id }); }
+ if (status === 'waiting' && conv.status !== 'waiting') {
+ await takeTurn(conv, deps);
+ require('./notify').later(conv, 'waiting');
+ require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id });
+ }
if (status === 'resolved' && conv.status !== 'resolved') require('./webhooks').later(conv.inbox_id, 'conversation.resolved', { conversationId: conv.id });
if (status === 'resolved' && conv.status !== 'resolved') require('./webhooks').later(conv.inbox_id, 'conversation.resolved', { conversationId: conv.id });
return { ...conv, status };
return { ...conv, status };
}
}
@@ -341,6 +361,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
@@ -341,6 +361,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
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 posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, 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 (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');
if (conv.status === 'bot') {
if (conv.status === 'bot') {
const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));
const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));
@@ -410,9 +431,10 @@ async function accountPost(account, conv, text, deps = {}) {
@@ -410,9 +431,10 @@ async function accountPost(account, conv, text, deps = {}) {
// ── Agents ─────────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Agents ─────────────────────────────────────────────────────────────────────────────────────────────────────────
-async function conversations(inbox, { status = null, limit = 100 } = {}) {
- const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : STATUSES.includes(status) ? 'AND c.status = ?' : '';
- const params = [inbox.id, ...(STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];
+async function conversations(inbox, { status = null, limit = 100, user = null } = {}) {
+ const mine = status === 'mine' && user;
+ const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : mine ? "AND c.status IN ('waiting', 'open') AND c.assignee_id = ?" : STATUSES.includes(status) ? 'AND c.status = ?' : '';
+ const params = [inbox.id, ...(mine ? [user.id] : STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];
const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id AND m.author <> 'system' ORDER BY m.id DESC LIMIT 1) AS last_body
const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id AND m.author <> 'system' ORDER BY m.id DESC LIMIT 1) AS last_body
FROM inbox_conversations c WHERE c.inbox_id = ? ${where}
FROM inbox_conversations c WHERE c.inbox_id = ? ${where}
ORDER BY CASE c.status WHEN 'waiting' THEN 0 WHEN 'open' THEN 1 WHEN 'bot' THEN 2 ELSE 3 END, COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, params);
ORDER BY CASE c.status WHEN 'waiting' THEN 0 WHEN 'open' THEN 1 WHEN 'bot' THEN 2 ELSE 3 END, COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, params);
@@ -443,6 +465,17 @@ async function setStatus(inbox, conv, user, status, deps = {}) {
@@ -443,6 +465,17 @@ async function setStatus(inbox, conv, user, status, deps = {}) {
return convShape(next);
return convShape(next);
}
}
+/** One of the inbox's people (or nobody: null) answers this conversation; any of them may assign it. */
+async function assign(inbox, conv, user, assigneeId, deps = {}) {
+ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people assign conversations');
+ const id = assigneeId == null || assigneeId === '' ? null : Number(assigneeId);
+ if (id != null && !(await db.get('SELECT 1 FROM inbox_members WHERE inbox_id = ? AND user_id = ?', [inbox.id, id]))) fail(422, 'inbox.assignee', 'Assign it to one of the inbox\'s people');
+ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ?', [id, conv.id]);
+ if (id != null) await require('./routing').noteAssigned(inbox.id, id);
+ broadcaster(deps)(inbox.id, null, { type: 'inbox_assigned', inbox: inbox.id, conversation: conv.id, assignee: id });
+ return convShape(await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conv.id]));
+}
+
async function markRead(inbox, conv) { await db.run('UPDATE inbox_conversations SET agent_unread = 0 WHERE id = ?', [conv.id]); }
async function markRead(inbox, conv) { await db.run('UPDATE inbox_conversations SET agent_unread = 0 WHERE id = ?', [conv.id]); }
// ── Vibe, the first responder ─────────────────────────────────────────────────────────────────────────────────────
// ── Vibe, the first responder ─────────────────────────────────────────────────────────────────────────────────────
@@ -543,6 +576,6 @@ function _reset() { inFlight.clear(); }
@@ -543,6 +576,6 @@ function _reset() { inFlight.clear(); }
module.exports = {
module.exports = {
InboxError, INBOX_RE, CONV_RE, LIMITS, MAX_INBOXES, AI_PER_CONVERSATION, hashToken,
InboxError, INBOX_RE, CONV_RE, LIMITS, MAX_INBOXES, AI_PER_CONVERSATION, hashToken,
create, update, remove, get, access, mine, members, addMember, removeMember, shape, publicConfig, plainText, peopleAnswer,
create, update, remove, get, access, mine, members, addMember, removeMember, shape, publicConfig, plainText, peopleAnswer,
- visit, visitorPost, handoff, rate, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
+ visit, visitorPost, handoff, rate, assign, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
aiAnswer, inboxPrompt, _reset,
aiAnswer, inboxPrompt, _reset,
};
};
@@ -29,10 +29,10 @@
@@ -29,10 +29,10 @@
* POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* 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 }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
- * GET /:id/conversations ?status=active|bot|waiting|open|resolved
+ * GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved
* GET /:id/conversations/:cid/messages ?after=
* GET /:id/conversations/:cid/messages ?after=
* POST /:id/conversations/:cid/messages { message } → 201: a person's answer (the conversation becomes open)
* POST /:id/conversations/:cid/messages { message } → 201: a person's answer (the conversation becomes open)
- * PATCH /:id/conversations/:cid { status: open | resolved | bot }
+ * PATCH /:id/conversations/:cid { status: open | resolved | bot, assignee_id: a person's id | null }
* POST /:id/conversations/:cid/read
* POST /:id/conversations/:cid/read
*/
*/
const express = require('express');
const express = require('express');
@@ -219,7 +219,7 @@ agents.delete('/:id/members/:userId', requireAuth, manageLimit, async (req, res)
@@ -219,7 +219,7 @@ agents.delete('/:id/members/:userId', requireAuth, manageLimit, async (req, res)
});
});
agents.get('/:id/conversations', requireAuth, async (req, res) => {
agents.get('/:id/conversations', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res); if (!inbox) return;
const inbox = await agentInbox(req, res); if (!inbox) return;
- noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit }) });
+ noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit, user: req.user }) });
});
});
agents.get('/:id/conversations/:cid/messages', requireAuth, async (req, res) => {
agents.get('/:id/conversations/:cid/messages', requireAuth, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
const conv = await agentConversation(req, res); if (!conv) return;
@@ -231,7 +231,14 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (
@@ -231,7 +231,14 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (
});
});
agents.patch('/:id/conversations/:cid', requireAuth, manageLimit, async (req, res) => {
agents.patch('/:id/conversations/:cid', requireAuth, manageLimit, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
const conv = await agentConversation(req, res); if (!conv) return;
- try { res.json({ conversation: await inboxes.setStatus(req.inbox, conv, req.user, req.body && req.body.status) }); } catch (err) { send(res, err); }
+ const b = req.body || {};
+ try {
+ let out = null;
+ if (Object.prototype.hasOwnProperty.call(b, 'assignee_id')) out = await inboxes.assign(req.inbox, conv, req.user, b.assignee_id);
+ if (b.status !== undefined) out = await inboxes.setStatus(req.inbox, { ...conv, assignee_id: out ? out.assignee_id : conv.assignee_id }, req.user, b.status);
+ if (!out) return res.status(422).json({ error: 'Send a status or an assignee_id', code: 'inbox.nothing' });
+ res.json({ conversation: out });
+ } catch (err) { send(res, err); }
});
});
agents.post('/:id/conversations/:cid/read', requireAuth, async (req, res) => {
agents.post('/:id/conversations/:cid/read', requireAuth, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
const conv = await agentConversation(req, res); if (!conv) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+/**
+ * Who answers a support conversation (server/inbox/inbox.js): one of the inbox's people takes it by answering, is
+ * assigned by a teammate, or, with turns on (inboxes.route = 'round_robin'), a conversation that starts waiting goes
+ * to the person whose turn it is: the one assigned longest ago (inbox_members.last_assigned_at). Notifications then go
+ * to that person only (server/inbox/notify.js already tells the assignee first).
+ */
+const db = require('../db/database');
+
+/** The person whose turn it is, or null (turns off, or nobody in the inbox). */
+async function nextInTurn(inbox) {
+ if (inbox.route !== 'round_robin') return null;
+ const row = await db.get(`SELECT user_id FROM inbox_members WHERE inbox_id = ?
+ ORDER BY last_assigned_at IS NOT NULL, last_assigned_at, role DESC, user_id LIMIT 1`, [inbox.id]);
+ return row ? Number(row.user_id) : null;
+}
+
+async function noteAssigned(inboxId, userId) {
+ if (userId == null) return;
+ await db.run('UPDATE inbox_members SET last_assigned_at = ov_now_iso() WHERE inbox_id = ? AND user_id = ?', [inboxId, userId]);
+}
+
+module.exports = { nextInTurn, noteAssigned };
@@ -120,22 +120,25 @@ ${list}
@@ -120,22 +120,25 @@ ${list}
router.get('/inbox/:id', async (req, res) => {
router.get('/inbox/:id', async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const a = await agentInbox(req, res); if (!a) return;
const { actor, inbox } = a;
const { actor, inbox } = a;
- const filter = ['active', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
- const list = await inboxes.conversations(inbox, { status: filter });
+ const filter = ['active', 'mine', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
+ const list = await inboxes.conversations(inbox, { status: filter, user: actor.user });
+ const team = await inboxes.members(inbox);
+ const nameOf = (id) => { const m = team.find((x) => x.user_id === Number(id)); return m ? (m.display_name || m.username) : null; };
const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
const saved = await inboxes.savedReplies(inbox);
const saved = await inboxes.savedReplies(inbox);
const msgs = current ? await inboxes.messages(current) : [];
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
if (current) await inboxes.markRead(inbox, current);
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">${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>`;
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>`;
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>`;
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">
+ 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 actions = current ? `${assignForm}<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
${current.status !== 'resolved' ? '<button name="status" value="resolved">Resolve</button>' : '<button name="status" value="open">Reopen</button>'}
${current.status !== 'resolved' ? '<button name="status" value="resolved">Resolve</button>' : '<button name="status" value="open">Reopen</button>'}
${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>` : '';
${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>` : '';
const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">
const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">
@@ -172,6 +175,12 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
@@ -172,6 +175,12 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
try { await inboxes.agentPost(a.inbox, conv, a.actor.user, req.body && req.body.message); } catch { /* the page shows the thread as it is */ }
try { await inboxes.agentPost(a.inbox, conv, a.actor.user, req.body && req.body.message); } catch { /* the page shows the thread as it is */ }
res.redirect(303, `/inbox/${a.inbox.id}?c=${conv.id}`);
res.redirect(303, `/inbox/${a.inbox.id}?c=${conv.id}`);
});
});
+ router.post('/inbox/:id/c/:cid/assign', formLimit, sameSite, async (req, res) => {
+ const a = await agentInbox(req, res); if (!a) return;
+ const conv = await inboxes.conversation(a.inbox, req.params.cid);
+ if (conv) { try { await inboxes.assign(a.inbox, conv, a.actor.user, req.body && req.body.assignee); } catch { /* unchanged */ } }
+ res.redirect(303, `/inbox/${a.inbox.id}${conv ? `?c=${conv.id}` : ''}`);
+ });
router.post('/inbox/:id/c/:cid/status', formLimit, sameSite, async (req, res) => {
router.post('/inbox/:id/c/:cid/status', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const a = await agentInbox(req, res); if (!a) return;
const conv = await inboxes.conversation(a.inbox, req.params.cid);
const conv = await inboxes.conversation(a.inbox, req.params.cid);
@@ -222,6 +231,7 @@ ${hooks.length < webhooks.MAX_WEBHOOKS ? `<form class="oc-form" method="post" ac
@@ -222,6 +231,7 @@ ${hooks.length < webhooks.MAX_WEBHOOKS ? `<form class="oc-form" method="post" ac
<label>Greeting <input name="greeting" maxlength="300" value="${esc(shape.greeting)}"></label>
<label>Greeting <input name="greeting" maxlength="300" value="${esc(shape.greeting)}"></label>
<label>Colour <input name="color" type="color" value="${esc(shape.color)}"></label>
<label>Colour <input name="color" type="color" value="${esc(shape.color)}"></label>
<label class="oc-check"><input type="checkbox" name="ai_enabled" value="1"${shape.ai_enabled ? ' checked' : ''}> Vibe answers first</label>
<label class="oc-check"><input type="checkbox" name="ai_enabled" value="1"${shape.ai_enabled ? ' checked' : ''}> Vibe answers first</label>
+<label class="oc-check"><input type="checkbox" name="route" value="round_robin"${shape.route === 'round_robin' ? ' checked' : ''}> Take turns: a visitor waiting for a person goes to the next of your people, and only they are told</label>
<label class="oc-check"><input type="checkbox" name="handoff" value="1"${shape.handoff ? ' checked' : ''}> People answer here too (visitors can ask for a person; off: a site assistant where only Vibe answers)</label>
<label class="oc-check"><input type="checkbox" name="handoff" value="1"${shape.handoff ? ' checked' : ''}> People answer here too (visitors can ask for a person; off: a site assistant where only Vibe answers)</label>
<fieldset class="oc-fieldset"><legend>Hours your people answer</legend>
<fieldset class="oc-fieldset"><legend>Hours your people answer</legend>
<label class="oc-check"><input type="checkbox" name="hours_on" value="1"${shape.hours ? ' checked' : ''}> Set hours: outside them, visitors see when you are back, and Vibe tells them</label>
<label class="oc-check"><input type="checkbox" name="hours_on" value="1"${shape.hours ? ' checked' : ''}> Set hours: outside them, visitors see when you are back, and Vibe tells them</label>
@@ -250,7 +260,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
@@ -250,7 +260,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
const b = req.body || {};
const b = req.body || {};
try {
try {
const hours = b.hours_on === '1' ? { tz: b.hours_tz, days: [].concat(b.hours_days || []), start: b.hours_start, end: b.hours_end } : null;
const hours = b.hours_on === '1' ? { tz: b.hours_tz, days: [].concat(b.hours_days || []), start: b.hours_start, end: b.hours_end } : null;
- await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });
+ await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', route: b.route === 'round_robin' ? 'round_robin' : 'none', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }
});
});
@@ -386,6 +386,28 @@ t('business hours: outside them the panel says when the team is back, a handoff
@@ -386,6 +386,28 @@ t('business hours: outside them the panel says when the team is back, a handoff
assert.strictEqual((await widget('GET', '')).body.inbox.away, null, 'no hours: always open');
assert.strictEqual((await widget('GET', '')).body.inbox.away, null, 'no hours: always open');
});
});
+t('routing: taking turns hands each waiting visitor to the next person; anyone of the people assigns; Mine lists theirs', async () => {
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'random' })).status, 422);
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'round_robin' })).status, 200);
+ await h.db.run('UPDATE inbox_members SET last_assigned_at = NULL WHERE inbox_id = ?', [inbox.id]);
+ const waiting = async (msg) => {
+ const v = (await widget('POST', '/visit', {})).body.token;
+ const cid = (await widget('POST', '/messages', { token: v, message: msg })).body.conversation.id;
+ await widget('POST', '/handoff', { token: v });
+ return (await h.db.get('SELECT id, assignee_id FROM inbox_conversations WHERE id = ?', [cid]));
+ };
+ const one = await waiting('First in line'), two = await waiting('Second in line'), three = await waiting('Third in line');
+ assert.deepStrictEqual([one, two, three].map((c) => Number(c.assignee_id)), [ann.id, bob.id, ann.id], 'turns go round the people');
+ const mine = (await agentsApi('GET', `/${inbox.id}/conversations?status=mine`, bob)).body.conversations.map((c) => c.id);
+ assert.ok(mine.includes(two.id) && !mine.includes(one.id), 'Mine is what was handed to them');
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: bob.id })).body.conversation.assignee_id, bob.id, 'one of the people takes it');
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: 424242 })).status, 422, 'only to one of the people');
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, ann, { assignee_id: null })).body.conversation.assignee_id, null);
+ const app = await fetch(`${h.base}/inbox/${inbox.id}?f=mine&c=${two.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
+ assert.ok(app.includes('Answered by') && app.includes(`data-conv="${two.id}"`) && app.includes('aria-current="page">Mine</a>'));
+ await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'none' });
+});
+
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);