openvibers/openvibe.chat Commits
Support inboxes: voice calls (one of the team calls from a conversation; the visitor joins from any site's chat panel with their token; either side or resolving ends it) (#159)
Changes
@@@ -495,6 +495,14 @@ for people and for companies, with "direct live support with an agent", "fully m`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`("Try it" in the settings), `DELETE /:id/actions/:actionId`.++- **Voice calls** (call escalation; `CHAT_CALLS` on): one of the inbox's people presses Call on a conversation (`POST++ /api/chat/inboxes/:id/conversations/:cid/call` → `{ channel }`). The thread says so, and the visitor's panel offers++ "Join the call" or "Not now". The call is `support-<conversation id>` on `/ws/call`, audio and peer to peer++ (`public/web/support-call.js`), at most four people. The visitor joins with their visitor token (a `support-visitor`++ message); the inbox's people join as themselves; anyone else gets "not found". Either side ends it for everyone++ (`DELETE …/call`, or the visitor's `POST /api/chat/widget/inbox/:id/call/end { token }`), and resolving the++ conversation ends it too. A conversation carries `call: true` while one is open. The widget's frame may ask for the++ microphone (the browser still asks the visitor).- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finishedconversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet@@@ -57,7 +57,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inboxwhose people do not answer) **modes** of the widget; pictures in the embed (built for signed-in visitors;-- anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation.++ anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation++ (built: a voice call the team starts, the visitor joins from the panel; next, sharing their screen in it).4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack andDiscord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an@@@ -71,6 +71,14 @@ a { color: var(--accent); }.re-signin[disabled] { opacity: .65; cursor: default; }.em-head a.em-x { color: inherit; text-decoration: none; }.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }++.em-call { list-style: none; justify-self: stretch; padding: 12px 14px; border-radius: 16px; border: 1px solid var(--accent); background: var(--panel); } /* a voice call the team started */++.em-call-text { margin: 0 0 10px; font-weight: 600; }++.em-call-row { display: flex; gap: 8px; flex-wrap: wrap; }++.em-call button { all: unset; cursor: pointer; padding: 8px 14px; border-radius: 999px; font-weight: 700; font-size: .9rem; }++.em-call button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.em-call button.em-call-join { background: var(--accent); color: var(--on-accent, #fff); } /* after `all: unset` above */++.em-call button.em-call-no { box-shadow: inset 0 0 0 1px var(--line); }++.em-call button.em-call-hang { background: #dc2626; color: #fff; }.em-me .em-pics { justify-content: flex-end; }.em-pic { display: block; line-height: 0; border-radius: 12px; }.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */@@@ -125,6 +125,7 @@if (m.type === 'inbox_message') render(m.message);else if (m.type === 'inbox_status') setStatus(m.status);else if (m.type === 'inbox_typing') showTyping(m.who);++ else if (m.type === 'inbox_call') { if (m.state === 'ringing') callCard(m.by); else endCallCard(); }};ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };}@@@ -163,6 +164,7 @@conv = x.d.conversation;(x.d.messages || []).forEach(render);if (conv) setStatus(conv.status);++ if (conv && conv.call) callCard(null);if (!ws) connect(); else watch();}).catch(function () { errorLine('You seem to be offline.'); });}@@@ -251,6 +253,43 @@});fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });}++ // A voice call one of the team started (server/inbox/inbox.js startCall): a card in the thread to join it, peer to++ // peer through support-call.js with this visitor's token; "Not now" or hanging up ends it for everyone.++ var callEl = null, call = null;++ function callCard(by) {++ if (!conv || !window.OVSupportCall) return;++ if (!callEl) { callEl = el('li', 'em-call'); callEl.setAttribute('role', 'group'); callEl.setAttribute('aria-label', 'Voice call'); thread.appendChild(callEl); }++ if (call) return;++ callEl.textContent = '';++ callEl.appendChild(el('p', 'em-call-text', (by || 'The team') + ' would like to talk. Join the voice call?'));++ var row = el('div', 'em-call-row');++ var yes = el('button', 'em-call-join', 'Join the call'); yes.type = 'button';++ var no = el('button', 'em-call-no', 'Not now'); no.type = 'button';++ yes.addEventListener('click', joinCall);++ no.addEventListener('click', function () { post('/call/end').catch(function () {}); endCallCard(); });++ row.appendChild(yes); row.appendChild(no); callEl.appendChild(row);++ bottom(); toParent({ type: 'message', author: 'agent' });++ }++ function joinCall() {++ var text = el('p', 'em-call-text', 'Joining the call…');++ callEl.textContent = ''; callEl.appendChild(text);++ var row = el('div', 'em-call-row');++ var mute = el('button', 'em-call-no', 'Mute'); mute.type = 'button'; mute.setAttribute('aria-pressed', 'false');++ var hang = el('button', 'em-call-hang', 'Hang up'); hang.type = 'button';++ row.appendChild(mute); row.appendChild(hang); callEl.appendChild(row);++ call = window.OVSupportCall.join({++ channel: 'support-' + conv.id, visitorToken: token,++ onState: function (s, t) { if (s === 'in') text.textContent = t || 'You are in the call.'; else if (s === 'ended' || s === 'error') { call = null; endCallCard(t); } else text.textContent = t; },++ onPeople: function (names) { text.textContent = names.length ? 'In the call with ' + names.join(', ') + '.' : 'Waiting for the team to join…'; },++ });++ mute.addEventListener('click', function () { if (!call) return; call.mute(!call.muted); mute.textContent = call.muted ? 'Unmute' : 'Mute'; mute.setAttribute('aria-pressed', call.muted ? 'true' : 'false'); });++ hang.addEventListener('click', function () { post('/call/end').catch(function () {}); if (call) call.leave(); });++ }++ function endCallCard(text) {++ if (call) { var c = call; call = null; c.leave(); }++ if (callEl) { callEl.remove(); callEl = null; }++ if (text && text !== 'You left the call.') errorLine('');++ }personBtn.addEventListener('click', function () {personBtn.disabled = true;post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })@@@ -110,6 +110,8 @@} 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')); }++ } else if (m.type === 'inbox_call' && m.conversation === cfg.conversation) {++ if (m.state === 'ringing') callOffered(m.by); else callGone();} else if (m.type === 'inbox_status') {touchList(m.conversation, null, m.status);if (m.conversation === cfg.conversation) { var h = document.querySelector('.vb-title .vb-st'); if (h) h.replaceWith(chip(m.status)); }@@@ -230,4 +232,46 @@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();++++ // A voice call with the visitor (server/inbox/inbox.js startCall, support-call.js): Call starts it and joins; anyone++ // else of the team on this conversation is offered to join; End call ends it for everyone, the visitor included.++ var callBtn = document.getElementById('ib-call'), callBar = document.getElementById('ib-callbar');++ var callText = document.getElementById('ib-call-text'), callJoin = document.getElementById('ib-call-join');++ var callMute = document.getElementById('ib-call-mute'), callEnd = document.getElementById('ib-call-end');++ var call = null;++ function callApi(method) {++ return fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/call', { method: method, credentials: 'same-origin' })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); });++ }++ function showBar(text) { callBar.hidden = false; callText.textContent = text; if (callBtn) callBtn.hidden = true; }++ function joinCall() {++ if (call) return;++ callJoin.hidden = true; callMute.hidden = false; callMute.textContent = 'Mute'; callMute.setAttribute('aria-pressed', 'false');++ showBar('Joining the call…');++ call = window.OVSupportCall.join({++ channel: 'support-' + cfg.conversation, token: token(),++ onState: function (s, t) { if (s === 'ended' || s === 'error') { call = null; callGone(t); } else if (t) callText.textContent = t; },++ onPeople: function (names) { callText.textContent = names.length ? 'In the call with ' + names.join(', ') + '.' : 'Calling… the visitor sees an invitation in their chat.'; },++ });++ }++ function callOffered(by) { if (call) return; showBar(by ? by + ' started a voice call.' : 'A voice call with this visitor is open.'); callJoin.hidden = false; callMute.hidden = true; }++ function callGone(text) {++ if (call) { var c = call; call = null; c.leave(); }++ if (!callBar) return;++ callBar.hidden = true;++ if (callBtn) callBtn.hidden = false;++ if (text && text !== 'You left the call.' && text !== 'The call ended.') alert(text);++ }++ if (callBtn && callBar && window.OVSupportCall) {++ callBtn.hidden = false;++ if (cfg.call) callOffered(null);++ callBtn.addEventListener('click', function () {++ callBtn.disabled = true;++ callApi('POST').then(function (x) { if (x.ok) joinCall(); else alert((x.d && x.d.error) || 'The call did not start.'); })++ .catch(function () { alert('You seem to be offline.'); }).then(function () { callBtn.disabled = false; });++ });++ callJoin.addEventListener('click', joinCall);++ callMute.addEventListener('click', function () { if (!call) return; call.mute(!call.muted); callMute.textContent = call.muted ? 'Unmute' : 'Mute'; callMute.setAttribute('aria-pressed', call.muted ? 'true' : 'false'); });++ callEnd.addEventListener('click', function () { callApi('DELETE').catch(function () {}); callGone(); });++ }})();nnew file mode 100644@@@ -0,0 +1,118 @@++/* A support call (server/calls/call-server.js, channel support-<conversation>): a voice call between an inbox's people++ and its visitor, peer to peer (a full mesh, at most four), signalled over /ws/call with the protocol call.js speaks.++ The visitor's panel (embed.js) joins with the visitor token, the inbox page (inbox.js) with the person's session.++++ var call = OVSupportCall.join({ channel: 'support-cnv_…', visitorToken: '…' | token: '…',++ onState: function (state, text) {}, // joining | in | ended | error++ onPeople: function (names) {} });++ call.mute(true); call.leave();++++ Audio only; the microphone is asked for when joining (no microphone: you listen). */++(function () {++ 'use strict';++ if (!window.RTCPeerConnection || !window.WebSocket) return;++++ function join(opts) {++ var ws = null, localStream = null, myPeerId = null, ended = false, muted = false;++ var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];++ var peers = new Map(), people = new Map();++ var state = function (s, text) { if (opts.onState) opts.onState(s, text || ''); };++ var send = function (o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); };++ var names = function () {++ if (!opts.onPeople) return;++ var list = []; people.forEach(function (p, id) { if (id !== myPeerId) list.push(p.displayName || p.username || 'Someone'); });++ opts.onPeople(list);++ };++++ function closePeer(id) {++ var p = peers.get(id); if (!p) return;++ try { p.pc.close(); } catch (e) { /* */ }++ if (p.audio) { p.audio.srcObject = null; p.audio.remove(); }++ peers.delete(id);++ }++ function peer(id, initiator) {++ closePeer(id);++ var pc = new RTCPeerConnection({ iceServers: iceServers });++ var p = { pc: pc, audio: null, pending: [] };++ peers.set(id, p);++ if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });++ else if (initiator) pc.addTransceiver('audio', { direction: 'recvonly' });++ pc.onicecandidate = function (e) { if (e.candidate) send({ type: 'ice-candidate', targetPeerId: id, candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate }); };++ pc.ontrack = function (e) {++ if (!p.audio) { p.audio = document.createElement('audio'); p.audio.autoplay = true; p.audio.hidden = true; document.body.appendChild(p.audio); }++ p.audio.srcObject = (e.streams && e.streams[0]) || new MediaStream([e.track]);++ };++ if (initiator) {++ pc.createOffer().then(function (o) { return pc.setLocalDescription(o); })++ .then(function () { send({ type: 'offer', targetPeerId: id, sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } }); })++ .catch(function () { state('error', 'Could not start the call.'); });++ }++ return p;++ }++ function flush(p) { var c; while ((c = p.pending.shift())) p.pc.addIceCandidate(c).catch(function () {}); }++++ function onMessage(m) {++ switch (m.type) {++ case 'welcome':++ myPeerId = m.peerId;++ (m.participants || []).forEach(function (x) { people.set(x.peerId, x); if (x.peerId !== myPeerId) peer(x.peerId, true); });++ state('in', localStream ? '' : 'No microphone: you can listen.'); names();++ break;++ case 'peer-joined': people.set(m.peerId, m); peer(m.peerId, false); names(); break;++ case 'peer-left': people.delete(m.peerId); closePeer(m.peerId); names(); break;++ case 'offer': {++ var p = peers.get(m.fromPeerId) || peer(m.fromPeerId, false);++ p.pc.setRemoteDescription(m.sdp).then(function () { flush(p); return p.pc.createAnswer(); })++ .then(function (a) { return p.pc.setLocalDescription(a); })++ .then(function () { send({ type: 'answer', targetPeerId: m.fromPeerId, sdp: { type: p.pc.localDescription.type, sdp: p.pc.localDescription.sdp } }); })++ .catch(function () { state('error', 'Could not connect the call.'); });++ break;++ }++ case 'answer': { var q = peers.get(m.fromPeerId); if (q) q.pc.setRemoteDescription(m.sdp).then(function () { flush(q); }).catch(function () {}); break; }++ case 'ice-candidate': {++ var r = peers.get(m.fromPeerId); if (!r || !m.candidate) break;++ if (r.pc.remoteDescription) r.pc.addIceCandidate(m.candidate).catch(function () {}); else r.pending.push(m.candidate);++ break;++ }++ case 'call-ended': case 'kicked': stop('ended', 'The call ended.'); break;++ case 'error': stop('error', m.message || 'The call is not available.'); break;++ default: break;++ }++ }++++ function stop(s, text) {++ if (ended) return;++ ended = true;++ peers.forEach(function (p, id) { closePeer(id); });++ people.clear();++ if (ws) { try { ws.close(); } catch (e) { /* */ } ws = null; }++ if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; }++ state(s, text);++ }++++ state('joining', 'Joining the call…');++ var ice = fetch('/api/chat/ice-servers', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { if (d && Array.isArray(d.iceServers) && d.iceServers.length) iceServers = d.iceServers; }).catch(function () {});++ var mic = navigator.mediaDevices && navigator.mediaDevices.getUserMedia++ ? navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }).then(function (s) { if (ended) s.getTracks().forEach(function (t) { t.stop(); }); else localStream = s; }, function () {})++ : Promise.resolve();++ Promise.all([ice, mic]).then(function () {++ if (ended) return;++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(opts.channel));++ ws.onopen = function () {++ if (opts.visitorToken) send({ type: 'support-visitor', token: opts.visitorToken });++ else if (opts.token) send({ type: 'auth-update', token: opts.token });++ };++ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };++ ws.onclose = function () { stop('ended', myPeerId ? 'The call ended.' : 'Could not reach the call.'); };++ });++++ return {++ mute: function (on) { muted = !!on; if (localStream) localStream.getAudioTracks().forEach(function (t) { t.enabled = !muted; }); send({ type: 'mute', muted: muted }); },++ leave: function () { stop('ended', 'You left the call.'); },++ get muted() { return muted; },++ };++ }++++ window.OVSupportCall = { join: join };++})();@@@ -239,6 +239,13 @@ body.oc-app-body { overflow: hidden; }.ib-actions { display: flex; gap: 6px; }.ib-actions button { padding: 6px 12px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .85rem; cursor: pointer; }.ib-actions button:hover { border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }++/* A voice call with the visitor: the Call button beside the actions, and the bar above the reply box while one is open. */++.ib-call-btn { margin-left: 6px; padding: 6px 14px; border-radius: 10px; border: 0; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, #fff); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }++.ib-call-btn[hidden], .ib-callbar[hidden], .ib-callbar button[hidden] { display: none; }++.ib-callbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--oc-accent); background: var(--oc-panel); }++.ib-call-text { flex: 1 1 200px; font-weight: 600; }++.ib-callbar button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--oc-line); background: transparent; color: var(--oc-text); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }++.ib-callbar .ib-call-end { border-color: transparent; background: #dc2626; color: #fff; }.ib-sys { text-align: center; color: var(--oc-muted); font-size: .82rem; }.ib-who { margin: 0 0 2px; font-size: .74rem; font-weight: 700; color: var(--oc-muted); }.ib-av { flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 700; }@@@ -74,7 +74,7 @@if (frame) return;frame = document.createElement('iframe');frame.title = 'Chat';-- frame.allow = 'clipboard-write';++ frame.allow = 'clipboard-write; microphone; autoplay'; // a voice call the team starts (the browser still asks)frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1': base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500)) + (identity ? '&id=1' : '');frame.addEventListener('load', sendIdentity);@@@ -59,6 +59,10 @@ const KICK_COOLDOWN_MS = 60 * 1000;const PUBLIC_CHANNEL_ID = 'public';const MODES = ['mic', 'mic+cam', 'cam+mic'];const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;++// A support call (server/inbox/inbox.js startCall): `support-<conversation id>`, open while one of the inbox's people++// keeps it; the inbox's people join as themselves, the visitor with their visitor token (a support-visitor message).++const SUPPORT_CHANNEL = /^support-(cnv_[0-9A-HJKMNP-TV-Z]{26})$/;++const SUPPORT_MAX = 4;class CallServer {constructor() {@@@ -140,7 +144,7 @@ class CallServer {async listChannels(viewer = null) {const result = [];for (const [id, ch] of this.channels) {-- if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar++ if (ch.roomSlug || ch.support) continue; // call rooms are listed as rooms (openvibe.chat), support calls in their inboxif (ch.private && !await this._canSeePrivate(ch, viewer)) continue;const room = this.rooms.get(id);const participants = [];@@@ -179,6 +183,33 @@ class CallServer {if (this._notifyTimer.unref) this._notifyTimer.unref();}++ /** Open a support call for a conversation (one of the inbox's people asked): → the channel id. */++ startSupportCall({ inboxId, inboxName, convId, userId }) {++ const id = `support-${convId}`;++ if (!SUPPORT_CHANNEL.test(id)) throw new Error('not a conversation id');++ if (!this.channels.has(id)) {++ this.channels.set(id, {++ id, name: `${inboxName}: support call`, mode: 'mic', createdBy: userId, streamId: null, permanent: false,++ support: { inboxId, convId }, createdAt: Date.now(), maxParticipants: SUPPORT_MAX,++ });++ }++ return id;++ }++++ /** End a conversation's support call: everyone in it is told, the channel goes. → whether there was one. */++ async endSupportCall(convId) {++ const id = `support-${convId}`;++ if (!this.channels.has(id)) return false;++ await this.endCall(id, 'closed');++ this.channels.delete(id);++ this.callBans.delete(id);++ await lifecycle.channelClosed(id, 'closed').catch(() => {});++ return true;++ }++++ /** Whether a conversation has a support call open. */++ supportCallOpen(convId) { return this.channels.has(`support-${convId}`); }++/** The call room behind a `room-<slug>` channel id, or null. */async _roomOf(channelId) {const m = ROOM_CHANNEL.exec(String(channelId || ''));@@@ -283,7 +314,7 @@ class CallServer {return {peerId, username: info.user ? info.user.username : null,anonId: info.anonId || null,-- displayName: info.user ? (info.user.display_name || info.user.username) : info.anonId,++ displayName: info.supportName || (info.user ? (info.user.display_name || info.user.username) : info.anonId),userId: info.user ? info.user.id : null,avatarUrl: info.user ? info.user.avatar_url : null,profileColor: info.user ? info.user.profile_color : null,@@@ -294,6 +325,7 @@ class CallServer {speaking: info.speaking || false,nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null, badgeFX: cosmeticProfile.badgeFX || null,...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),++ ...(info.supportRole ? { supportRole: info.supportRole } : {}),};}@@@ -360,7 +392,7 @@ class CallServer {// browser's upgrade is anonymous, and a private call or a members-only room must still let it// in. A socket refused while anonymous waits briefly for that message.user = await this._presentedUser(early, user);-- let admission = await this._admission(channel, resolvedId, user);++ let admission = await this._admission(channel, resolvedId, user, early);if (admission.error) {// Admission may have awaited Live while an auth-update arrived in the buffer. A newly// presented identity (anonymous upgrade only: auth-update cannot switch an already@@@ -368,18 +400,19 @@ class CallServer {const presented = await this._presentedUser(early, user);if (presented && presented.id !== (user && user.id)) {user = presented;-- admission = await this._admission(channel, resolvedId, user);++ admission = await this._admission(channel, resolvedId, user, early);}}if (admission.error && !user) {await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);if (ws.readyState !== WebSocket.OPEN) return;user = await this._presentedUser(early, user);-- if (user) admission = await this._admission(channel, resolvedId, user);++ if (user || channel.support) admission = await this._admission(channel, resolvedId, user, early);}if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }const roomAccess = admission.roomAccess;const listenOnly = !!(roomAccess && !roomAccess.talk);++ const support = admission.support || null;const peerId = this._generatePeerId();const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);@@@ -425,6 +458,7 @@ class CallServer {const clientInfo = {ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),++ ...(support ? { supportRole: support.role, supportName: support.name } : {}),};this.clients.set(ws, { channelId: resolvedId, peerId });room.set(peerId, clientInfo);@@@ -484,8 +518,9 @@ class CallServer {}/** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */-- async _admission(channel, channelId, user) {++ async _admission(channel, channelId, user, early = []) {if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };++ if (channel.support) return await this._supportAdmission(channel, user, early);// A call room: the room's roles decide who is in and who talks.if (!channel.roomSlug) return { roomAccess: null };const room = await this._roomOf(channelId);@@@ -514,12 +549,37 @@ class CallServer {return next;}-- /** Resolves on the next auth-update (buffered by then), close, or timeout. */++ /**++ * A support call's door: one of the inbox's people (signed in) joins as themselves; the visitor shows their visitor++ * token in a support-visitor message and joins under the name they gave (else "Visitor"). Anyone else: not found.++ */++ async _supportAdmission(channel, user, early) {++ const inboxes = require('../inbox/inbox');++ const inbox = await inboxes.get(channel.support.inboxId);++ if (!inbox) return { error: 'Voice channel not found' };++ if (user) {++ const a = await inboxes.access(inbox, user).catch(() => null);++ if (a && a.role) return { roomAccess: null, support: { role: 'agent', name: null } };++ }++ let token = null;++ for (const data of early) {++ let msg;++ try { msg = JSON.parse(data); } catch { continue; }++ if (msg?.type === 'support-visitor' && typeof msg.token === 'string') { token = msg.token; break; }++ }++ const conv = token ? await inboxes.conversation(inbox, channel.support.convId).catch(() => null) : null;++ if (conv && conv.visitor_token_hash && conv.visitor_token_hash === inboxes.hashToken(token)) {++ return { roomAccess: null, support: { role: 'visitor', name: conv.visitor_name || 'Visitor' } };++ }++ return { error: 'Voice channel not found' };++ }++++ /** Resolves on the next auth-update or support-visitor message (buffered by then), close, or timeout. */_firstAuthMessage(ws, ms) {return new Promise((resolve) => {const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };const onMessage = (data) => {-- try { if (JSON.parse(data)?.type === 'auth-update') done(); } catch { /* wait */ }++ try { const t = JSON.parse(data)?.type; if (t === 'auth-update' || t === 'support-visitor') done(); } catch { /* wait */ }};const timer = setTimeout(done, ms);ws.on('message', onMessage);@@@ -194,11 +194,14 @@ async function removeMember(inbox, user, userId) {// ── Conversations and messages ─────────────────────────────────────────────────────────────────────────────────────++/** Whether a conversation has a voice call open now (the call server keeps calls in memory). */++function callOpen(id) { try { return require('../calls/call-server').supportCallOpen(id); } catch { return false; } }++const convShape = (c) => c && ({id: c.id, inbox_id: c.inbox_id, status: c.status, visitor_name: c.visitor_name || null, page_url: c.page_url || null,assignee_id: c.assignee_id ? Number(c.assignee_id) : null, agent_unread: Number(c.agent_unread) || 0,rating: c.rating == null ? null : Number(c.rating), rating_comment: c.rating_comment || null, account: c.visitor_user_id != null,-- verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,++ verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null, call: callOpen(c.id),created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,});// account: a visitor's message written signed in to OpenVibe, whose OpenVibe.Pics links show as pictures (anyone else's stay links).@@@ -468,10 +471,42 @@ async function setStatus(inbox, conv, user, status, deps = {}) {if (!['open', 'resolved', 'bot'].includes(status)) fail(422, 'inbox.status', 'A conversation is open, resolved, or back with Vibe');if (status === 'bot' && !Number(inbox.ai_enabled)) fail(409, 'inbox.ai_off', 'Vibe is off for this inbox');const next = await setStatusRow(conv, status, deps);-- if (status === 'resolved') await addMessage(conv, { author: 'system', body: 'This conversation was marked resolved. Write again any time to reopen it.' }, deps);++ if (status === 'resolved') {++ await endCall(inbox, conv, user, deps).catch(() => {});++ await addMessage(conv, { author: 'system', body: 'This conversation was marked resolved. Write again any time to reopen it.' }, deps);++ }return convShape(next);}++// ── Calls (docs/platform.md: call escalation) ──────────────────────────────────────────────────────────────────────++// One of the inbox's people starts a voice call from a conversation (server/calls/call-server.js support-<cid>); the++// visitor's panel offers to join it, peer to peer, no account needed. Either side ends it; resolving ends it too.++const nameOf = (user) => (user && (user.display_name || user.username)) || 'Someone';++++async function startCall(inbox, conv, user, deps = {}) {++ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people call visitors');++ if (conv.status === 'resolved') fail(409, 'inbox.resolved', 'Reopen the conversation to call');++ if (!config.calls.enabled && !deps.calls) fail(409, 'inbox.calls_off', 'Calls are not available here right now');++ const calls = deps.calls || require('../calls/call-server');++ const already = calls.supportCallOpen(conv.id);++ const channel = calls.startSupportCall({ inboxId: inbox.id, inboxName: inbox.name, convId: conv.id, userId: user.id });++ if (!already) {++ await addMessage(conv, { author: 'system', body: `${nameOf(user)} started a voice call.` }, deps);++ broadcaster(deps)(inbox.id, conv.id, { type: 'inbox_call', inbox: inbox.id, conversation: conv.id, state: 'ringing', channel, by: nameOf(user) });++ }++ return { channel, conversation: convShape(conv) };++}++++/** user: one of the inbox's people, or null for the visitor (their panel's "Not now" or hang up). */++async function endCall(inbox, conv, user, deps = {}) {++ if (user && !(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people end calls');++ const calls = deps.calls || require('../calls/call-server');++ if (!(await calls.endSupportCall(conv.id))) return { ended: false };++ await addMessage(conv, { author: 'system', body: user ? `${nameOf(user)} ended the call.` : 'The visitor ended the call.' }, deps);++ broadcaster(deps)(inbox.id, conv.id, { type: 'inbox_call', inbox: inbox.id, conversation: conv.id, state: 'ended' });++ return { ended: true };++}++/** 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');@@@ -590,5 +625,5 @@ 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, assign, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,-- aiAnswer, inboxPrompt, _reset,++ startCall, endCall, aiAnswer, inboxPrompt, _reset,};@@@ -11,6 +11,7 @@* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)++ * POST /call/end { token } → { ended }: the visitor declines or hangs up the conversation's voice call* POST /picture the body is a picture; X-Visitor-Token, an optional line in X-Caption → 201 { conversation, message,* picture } (signed in to OpenVibe only: it goes to OpenVibe.Pics as them; 403 inbox.sign_in_to_attach)*@@@ -38,6 +39,8 @@* 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, assignee_id: a person's id | null }++ * POST /:id/conversations/:cid/call → { channel }: a voice call with the visitor (/ws/call?channelId=support-<cid>)++ * DELETE /:id/conversations/:cid/call → { ended }* POST /:id/conversations/:cid/read*/const express = require('express');@@@ -102,6 +105,14 @@ widget.post('/messages', postLimit, async (req, res) => {const inbox = await inboxOr404(req, res); if (!inbox) return;try { res.status(201).json(await inboxes.visitorPost(inbox, tokenOf(req), { ...(req.body || {}), account: await accountOf(req) })); } catch (err) { send(res, err); }});++widget.post('/call/end', postLimit, async (req, res) => {++ const inbox = await inboxOr404(req, res); if (!inbox) return;++ try {++ const v = await inboxes.visit(inbox, tokenOf(req));++ if (!v.conversation) return res.json({ ended: false });++ res.json(await inboxes.endCall(inbox, await inboxes.conversation(inbox, v.conversation.id), null));++ } catch (err) { send(res, err); }++});widget.post('/handoff', postLimit, async (req, res) => {const inbox = await inboxOr404(req, res); if (!inbox) return;try { res.json({ conversation: await inboxes.handoff(inbox, tokenOf(req)) }); } catch (err) { send(res, err); }@@@ -288,6 +299,15 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (const conv = await agentConversation(req, res); if (!conv) return;try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, req.body && req.body.message) }); } catch (err) { send(res, err); }});++agents.post('/:id/conversations/:cid/call', requireAuth, manageLimit, async (req, res) => {++ const conv = await agentConversation(req, res); if (!conv) return;++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Calls are made with your own sign-in', code: 'inbox.person_only' });++ try { noStore(res).json(await inboxes.startCall(req.inbox, conv, req.user)); } catch (err) { send(res, err); }++});++agents.delete('/:id/conversations/:cid/call', requireAuth, manageLimit, async (req, res) => {++ const conv = await agentConversation(req, res); if (!conv) return;++ try { res.json(await inboxes.endCall(req.inbox, conv, req.user)); } catch (err) { send(res, err); }++});agents.patch('/:id/conversations/:cid', requireAuth, manageLimit, async (req, res) => {const conv = await agentConversation(req, res); if (!conv) return;const b = req.body || {};@@@ -43,6 +43,7 @@ function embedHtml(inbox) {<meta name="robots" content="noindex, nofollow"><link rel="stylesheet" href="${asset('embed.css')}"><script src="${asset('vibe-md.js')}" defer></script>++<script src="${asset('support-call.js')}" defer></script><script src="${asset('embed.js')}" defer></script></head><body style="--accent:${esc(inbox.color)}">@@@ -145,7 +146,7 @@ ${list}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>` : '';++${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>${current.status !== 'resolved' && config.calls.enabled ? '<button type="button" class="ib-call-btn" id="ib-call" hidden>Call</button>' : ''}` : '';const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}"><aside class="vb-rail" id="vb-rail" aria-label="Conversations"><div class="vb-rail-top"><a class="vb-new" href="/inbox/${esc(inbox.id)}/settings"><span class="ib-dot" style="background:${esc(inbox.color)}"></span><span>${esc(inbox.name)}</span></a>@@@ -162,14 +163,14 @@ ${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 're${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}</p>` : ''}${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`: `<div class="vb-hello"><h1>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h1><p class="vb-sub">${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`}</div>--${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">++${current ? `<div class="vb-dock"><div class="ib-callbar" id="ib-callbar" role="group" aria-label="Voice call" hidden><span class="ib-call-text" id="ib-call-text"></span><button type="button" id="ib-call-join" hidden>Join the call</button><button type="button" id="ib-call-mute" aria-pressed="false" hidden>Mute</button><button type="button" class="ib-call-end" id="ib-call-end">End call</button></div><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>${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">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</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, {-- actor, app: true, appScript: 'inbox.js', active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,-- page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },++ actor, app: true, appScript: 'inbox.js', scripts: ['support-call.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,++ page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },});});nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * Support calls (server/inbox/inbox.js startCall, server/calls/call-server.js support-<cid>): one of an inbox's people++ * starts a voice call from a conversation; the visitor joins with their visitor token, the inbox's people as themselves,++ * anyone else is refused; the visitor declining or hanging up ends it for everyone, and so does resolving; the panel++ * loads the call client and the inbox page shows the Call button.++ */++const assert = require('assert');++const WebSocket = require('ws');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('support calls');++let h, ann, bob, cat, inbox, token, cid;++const agentsApi = (method, path, u, body) => h.http(method, `/api/chat/inboxes${path}`, { token: u && u.token, body });++const widget = (path, body) => fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}${path}`, { method: body ? 'POST' : 'GET', headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then((r) => r.json());++++/** Join /ws/call: resolves with the socket and its first welcome or error. */++function join(channelId, { bearer = null, first = null } = {}) {++ return new Promise((resolve, reject) => {++ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?channelId=${encodeURIComponent(channelId)}`, { headers: { origin: 'https://openvibe.live', 'cf-connecting-ip': `203.0.113.${Math.floor(Math.random() * 200) + 20}`, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });++ const all = [];++ ws.all = all;++ ws.wait = (pred, ms = 3000) => new Promise((res, rej) => {++ const hit = all.find(pred); if (hit) return res(hit);++ const timer = setTimeout(() => rej(new Error(`timed out (got ${JSON.stringify(all.map((m) => m.type))})`)), ms);++ ws.on('message', function on(d) { let m; try { m = JSON.parse(d.toString()); } catch { return; } if (pred(m)) { clearTimeout(timer); ws.off('message', on); res(m); } });++ });++ ws.on('message', (d) => { try { all.push(JSON.parse(d.toString())); } catch { /* */ } });++ ws.on('open', async () => {++ if (first) ws.send(JSON.stringify(first));++ try { ws.first = await ws.wait((m) => m.type === 'welcome' || m.type === 'error'); resolve(ws); } catch (e) { reject(e); }++ });++ ws.on('error', reject);++ });++}++++t('boot (calls on)', async () => {++ h = await boot({ env: { CHAT_CALLS: '1', CHAT_INBOX_POSTS_PER_MINUTE: '500' } });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++ inbox = (await agentsApi('POST', '', ann, { name: 'Acme', ai_enabled: false })).body.inbox;++ await agentsApi('POST', `/${inbox.id}/members`, ann, { username: 'bob' });++ token = (await widget('/visit', {})).token;++ cid = (await widget('/messages', { token, message: 'Can someone talk me through the setup?', name: 'Dana' })).conversation.id;++});++++t('one of the people starts a call; the visitor and the people join, nobody else does', async () => {++ const before = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });++ assert.strictEqual(before.first.type, 'error', 'no call yet: nothing to join');++ before.close();++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, cat)).status, 404, 'not one of the people');++ const started = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob);++ assert.strictEqual(started.status, 200, JSON.stringify(started.body));++ assert.strictEqual(started.body.channel, `support-${cid}`);++ const seen = await widget(`/messages?token=${token}`);++ assert.strictEqual(seen.conversation.call, true, 'the panel learns there is a call');++ assert.ok(seen.messages.some((m) => m.author === 'system' && m.body === 'Bob started a voice call.'), JSON.stringify(seen.messages.map((m) => m.body)));++++ const visitor = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });++ assert.strictEqual(visitor.first.type, 'welcome', JSON.stringify(visitor.first));++ const agent = await join(`support-${cid}`, { bearer: bob.token });++ assert.strictEqual(agent.first.type, 'welcome');++ const dana = agent.first.participants.find((p) => p.supportRole === 'visitor');++ assert.ok(dana && dana.displayName === 'Dana', 'the people see the visitor by the name they gave');++ assert.ok(agent.first.participants.some((p) => p.supportRole === 'agent'));++ const stranger = await join(`support-${cid}`, { bearer: cat.token });++ assert.strictEqual(stranger.first.type, 'error', 'someone else is refused');++ const forged = await join(`support-${cid}`, { first: { type: 'support-visitor', token: 'x'.repeat(32) } });++ assert.strictEqual(forged.first.type, 'error', 'a wrong visitor token too');++ for (const ws of [stranger, forged]) ws.close();++++ // The visitor hangs up: everyone is told, the channel goes, the thread says so.++ assert.deepStrictEqual(await widget('/call/end', { token }), { ended: true });++ await Promise.all([visitor.wait((m) => m.type === 'call-ended'), agent.wait((m) => m.type === 'call-ended')]);++ assert.ok((await widget(`/messages?token=${token}`)).messages.some((m) => m.body === 'The visitor ended the call.'));++ assert.strictEqual((await widget(`/messages?token=${token}`)).conversation.call, false);++ for (const ws of [visitor, agent]) ws.close();++});++++t('resolving ends a call; a resolved conversation cannot be called; the pages carry the call', async () => {++ await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob);++ const visitor = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });++ assert.strictEqual(visitor.first.type, 'welcome');++ const page = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((r) => r.text());++ assert.ok(page.includes('id="ib-call"') && page.includes('id="ib-callbar"') && page.includes('support-call.js') && page.includes('"call":true'));++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${cid}`, bob, { status: 'resolved' })).status, 200);++ await visitor.wait((m) => m.type === 'call-ended');++ visitor.close();++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob)).body.code, 'inbox.resolved');++ const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((r) => r.text());++ assert.ok(panel.includes('support-call.js'), 'the panel loads the call client');++ const widgetJs = await fetch(`${h.base}/widget.js`).then((r) => r.text());++ assert.ok(widgetJs.includes("frame.allow = 'clipboard-write; microphone; autoplay'"), 'the panel may ask for the microphone');++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -495,6 +495,14 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -495,6 +495,14 @@ for people and for companies, with "direct live support with an agent", "fully m
`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
("Try it" in the settings), `DELETE /:id/actions/:actionId`.
("Try it" in the settings), `DELETE /:id/actions/:actionId`.
+- **Voice calls** (call escalation; `CHAT_CALLS` on): one of the inbox's people presses Call on a conversation (`POST
+ /api/chat/inboxes/:id/conversations/:cid/call` → `{ channel }`). The thread says so, and the visitor's panel offers
+ "Join the call" or "Not now". The call is `support-<conversation id>` on `/ws/call`, audio and peer to peer
+ (`public/web/support-call.js`), at most four people. The visitor joins with their visitor token (a `support-visitor`
+ message); the inbox's people join as themselves; anyone else gets "not found". Either side ends it for everyone
+ (`DELETE …/call`, or the visitor's `POST /api/chat/widget/inbox/:id/call/end { token }`), and resolving the
+ conversation ends it too. A conversation carries `call: true` while one is open. The widget's frame may ask for the
+ microphone (the browser still asks the visitor).
- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished
- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished
conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:
conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:
7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet
7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet
@@ -57,7 +57,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
@@ -57,7 +57,8 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox
3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox
whose people do not answer) **modes** of the widget; pictures in the embed (built for signed-in visitors;
whose people do not answer) **modes** of the widget; pictures in the embed (built for signed-in visitors;
- anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation.
+ anonymous ones once Pics scans anonymous uploads), then video and files; call escalation from a support conversation
+ (built: a voice call the team starts, the visitor joins from the panel; next, sharing their screen in it).
4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,
4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,
`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
@@ -71,6 +71,14 @@ a { color: var(--accent); }
@@ -71,6 +71,14 @@ a { color: var(--accent); }
.re-signin[disabled] { opacity: .65; cursor: default; }
.re-signin[disabled] { opacity: .65; cursor: default; }
.em-head a.em-x { color: inherit; text-decoration: none; }
.em-head a.em-x { color: inherit; text-decoration: none; }
.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.em-call { list-style: none; justify-self: stretch; padding: 12px 14px; border-radius: 16px; border: 1px solid var(--accent); background: var(--panel); } /* a voice call the team started */
+.em-call-text { margin: 0 0 10px; font-weight: 600; }
+.em-call-row { display: flex; gap: 8px; flex-wrap: wrap; }
+.em-call button { all: unset; cursor: pointer; padding: 8px 14px; border-radius: 999px; font-weight: 700; font-size: .9rem; }
+.em-call button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.em-call button.em-call-join { background: var(--accent); color: var(--on-accent, #fff); } /* after `all: unset` above */
+.em-call button.em-call-no { box-shadow: inset 0 0 0 1px var(--line); }
+.em-call button.em-call-hang { background: #dc2626; color: #fff; }
.em-me .em-pics { justify-content: flex-end; }
.em-me .em-pics { justify-content: flex-end; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
.em-pic { display: block; line-height: 0; border-radius: 12px; }
.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */
.em-pic img { display: block; width: 240px; max-width: 100%; height: auto; max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; } /* a definite width: the link is as wide as the picture, so it lines up under its bubble */
@@ -125,6 +125,7 @@
@@ -125,6 +125,7 @@
if (m.type === 'inbox_message') render(m.message);
if (m.type === 'inbox_message') render(m.message);
else if (m.type === 'inbox_status') setStatus(m.status);
else if (m.type === 'inbox_status') setStatus(m.status);
else if (m.type === 'inbox_typing') showTyping(m.who);
else if (m.type === 'inbox_typing') showTyping(m.who);
+ else if (m.type === 'inbox_call') { if (m.state === 'ringing') callCard(m.by); else endCallCard(); }
};
};
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
}
}
@@ -163,6 +164,7 @@
@@ -163,6 +164,7 @@
conv = x.d.conversation;
conv = x.d.conversation;
(x.d.messages || []).forEach(render);
(x.d.messages || []).forEach(render);
if (conv) setStatus(conv.status);
if (conv) setStatus(conv.status);
+ if (conv && conv.call) callCard(null);
if (!ws) connect(); else watch();
if (!ws) connect(); else watch();
}).catch(function () { errorLine('You seem to be offline.'); });
}).catch(function () { errorLine('You seem to be offline.'); });
}
}
@@ -251,6 +253,43 @@
@@ -251,6 +253,43 @@
});
});
fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });
fileIn.addEventListener('change', function () { var f = fileIn.files && fileIn.files[0]; fileIn.value = ''; sendPicture(f); });
}
}
+ // A voice call one of the team started (server/inbox/inbox.js startCall): a card in the thread to join it, peer to
+ // peer through support-call.js with this visitor's token; "Not now" or hanging up ends it for everyone.
+ var callEl = null, call = null;
+ function callCard(by) {
+ if (!conv || !window.OVSupportCall) return;
+ if (!callEl) { callEl = el('li', 'em-call'); callEl.setAttribute('role', 'group'); callEl.setAttribute('aria-label', 'Voice call'); thread.appendChild(callEl); }
+ if (call) return;
+ callEl.textContent = '';
+ callEl.appendChild(el('p', 'em-call-text', (by || 'The team') + ' would like to talk. Join the voice call?'));
+ var row = el('div', 'em-call-row');
+ var yes = el('button', 'em-call-join', 'Join the call'); yes.type = 'button';
+ var no = el('button', 'em-call-no', 'Not now'); no.type = 'button';
+ yes.addEventListener('click', joinCall);
+ no.addEventListener('click', function () { post('/call/end').catch(function () {}); endCallCard(); });
+ row.appendChild(yes); row.appendChild(no); callEl.appendChild(row);
+ bottom(); toParent({ type: 'message', author: 'agent' });
+ }
+ function joinCall() {
+ var text = el('p', 'em-call-text', 'Joining the call…');
+ callEl.textContent = ''; callEl.appendChild(text);
+ var row = el('div', 'em-call-row');
+ var mute = el('button', 'em-call-no', 'Mute'); mute.type = 'button'; mute.setAttribute('aria-pressed', 'false');
+ var hang = el('button', 'em-call-hang', 'Hang up'); hang.type = 'button';
+ row.appendChild(mute); row.appendChild(hang); callEl.appendChild(row);
+ call = window.OVSupportCall.join({
+ channel: 'support-' + conv.id, visitorToken: token,
+ onState: function (s, t) { if (s === 'in') text.textContent = t || 'You are in the call.'; else if (s === 'ended' || s === 'error') { call = null; endCallCard(t); } else text.textContent = t; },
+ onPeople: function (names) { text.textContent = names.length ? 'In the call with ' + names.join(', ') + '.' : 'Waiting for the team to join…'; },
+ });
+ mute.addEventListener('click', function () { if (!call) return; call.mute(!call.muted); mute.textContent = call.muted ? 'Unmute' : 'Mute'; mute.setAttribute('aria-pressed', call.muted ? 'true' : 'false'); });
+ hang.addEventListener('click', function () { post('/call/end').catch(function () {}); if (call) call.leave(); });
+ }
+ function endCallCard(text) {
+ if (call) { var c = call; call = null; c.leave(); }
+ if (callEl) { callEl.remove(); callEl = null; }
+ if (text && text !== 'You left the call.') errorLine('');
+ }
personBtn.addEventListener('click', function () {
personBtn.addEventListener('click', function () {
personBtn.disabled = true;
personBtn.disabled = true;
post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })
post('/handoff').then(function (x) { if (x.ok && x.d.conversation) { setStatus(x.d.conversation.status); catchUp(); } else errorLine((x.d && x.d.error) || 'Try again in a moment.'); })
@@ -110,6 +110,8 @@
@@ -110,6 +110,8 @@
} 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')); }
+ } else if (m.type === 'inbox_call' && m.conversation === cfg.conversation) {
+ if (m.state === 'ringing') callOffered(m.by); else callGone();
} else if (m.type === 'inbox_status') {
} else if (m.type === 'inbox_status') {
touchList(m.conversation, null, m.status);
touchList(m.conversation, null, m.status);
if (m.conversation === cfg.conversation) { var h = document.querySelector('.vb-title .vb-st'); if (h) h.replaceWith(chip(m.status)); }
if (m.conversation === cfg.conversation) { var h = document.querySelector('.vb-title .vb-st'); if (h) h.replaceWith(chip(m.status)); }
@@ -230,4 +232,46 @@
@@ -230,4 +232,46 @@
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();
+
+ // A voice call with the visitor (server/inbox/inbox.js startCall, support-call.js): Call starts it and joins; anyone
+ // else of the team on this conversation is offered to join; End call ends it for everyone, the visitor included.
+ var callBtn = document.getElementById('ib-call'), callBar = document.getElementById('ib-callbar');
+ var callText = document.getElementById('ib-call-text'), callJoin = document.getElementById('ib-call-join');
+ var callMute = document.getElementById('ib-call-mute'), callEnd = document.getElementById('ib-call-end');
+ var call = null;
+ function callApi(method) {
+ return fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/call', { method: method, credentials: 'same-origin' })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); });
+ }
+ function showBar(text) { callBar.hidden = false; callText.textContent = text; if (callBtn) callBtn.hidden = true; }
+ function joinCall() {
+ if (call) return;
+ callJoin.hidden = true; callMute.hidden = false; callMute.textContent = 'Mute'; callMute.setAttribute('aria-pressed', 'false');
+ showBar('Joining the call…');
+ call = window.OVSupportCall.join({
+ channel: 'support-' + cfg.conversation, token: token(),
+ onState: function (s, t) { if (s === 'ended' || s === 'error') { call = null; callGone(t); } else if (t) callText.textContent = t; },
+ onPeople: function (names) { callText.textContent = names.length ? 'In the call with ' + names.join(', ') + '.' : 'Calling… the visitor sees an invitation in their chat.'; },
+ });
+ }
+ function callOffered(by) { if (call) return; showBar(by ? by + ' started a voice call.' : 'A voice call with this visitor is open.'); callJoin.hidden = false; callMute.hidden = true; }
+ function callGone(text) {
+ if (call) { var c = call; call = null; c.leave(); }
+ if (!callBar) return;
+ callBar.hidden = true;
+ if (callBtn) callBtn.hidden = false;
+ if (text && text !== 'You left the call.' && text !== 'The call ended.') alert(text);
+ }
+ if (callBtn && callBar && window.OVSupportCall) {
+ callBtn.hidden = false;
+ if (cfg.call) callOffered(null);
+ callBtn.addEventListener('click', function () {
+ callBtn.disabled = true;
+ callApi('POST').then(function (x) { if (x.ok) joinCall(); else alert((x.d && x.d.error) || 'The call did not start.'); })
+ .catch(function () { alert('You seem to be offline.'); }).then(function () { callBtn.disabled = false; });
+ });
+ callJoin.addEventListener('click', joinCall);
+ callMute.addEventListener('click', function () { if (!call) return; call.mute(!call.muted); callMute.textContent = call.muted ? 'Unmute' : 'Mute'; callMute.setAttribute('aria-pressed', call.muted ? 'true' : 'false'); });
+ callEnd.addEventListener('click', function () { callApi('DELETE').catch(function () {}); callGone(); });
+ }
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+/* A support call (server/calls/call-server.js, channel support-<conversation>): a voice call between an inbox's people
+ and its visitor, peer to peer (a full mesh, at most four), signalled over /ws/call with the protocol call.js speaks.
+ The visitor's panel (embed.js) joins with the visitor token, the inbox page (inbox.js) with the person's session.
+
+ var call = OVSupportCall.join({ channel: 'support-cnv_…', visitorToken: '…' | token: '…',
+ onState: function (state, text) {}, // joining | in | ended | error
+ onPeople: function (names) {} });
+ call.mute(true); call.leave();
+
+ Audio only; the microphone is asked for when joining (no microphone: you listen). */
+(function () {
+ 'use strict';
+ if (!window.RTCPeerConnection || !window.WebSocket) return;
+
+ function join(opts) {
+ var ws = null, localStream = null, myPeerId = null, ended = false, muted = false;
+ var iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
+ var peers = new Map(), people = new Map();
+ var state = function (s, text) { if (opts.onState) opts.onState(s, text || ''); };
+ var send = function (o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); };
+ var names = function () {
+ if (!opts.onPeople) return;
+ var list = []; people.forEach(function (p, id) { if (id !== myPeerId) list.push(p.displayName || p.username || 'Someone'); });
+ opts.onPeople(list);
+ };
+
+ function closePeer(id) {
+ var p = peers.get(id); if (!p) return;
+ try { p.pc.close(); } catch (e) { /* */ }
+ if (p.audio) { p.audio.srcObject = null; p.audio.remove(); }
+ peers.delete(id);
+ }
+ function peer(id, initiator) {
+ closePeer(id);
+ var pc = new RTCPeerConnection({ iceServers: iceServers });
+ var p = { pc: pc, audio: null, pending: [] };
+ peers.set(id, p);
+ if (localStream) localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
+ else if (initiator) pc.addTransceiver('audio', { direction: 'recvonly' });
+ pc.onicecandidate = function (e) { if (e.candidate) send({ type: 'ice-candidate', targetPeerId: id, candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate }); };
+ pc.ontrack = function (e) {
+ if (!p.audio) { p.audio = document.createElement('audio'); p.audio.autoplay = true; p.audio.hidden = true; document.body.appendChild(p.audio); }
+ p.audio.srcObject = (e.streams && e.streams[0]) || new MediaStream([e.track]);
+ };
+ if (initiator) {
+ pc.createOffer().then(function (o) { return pc.setLocalDescription(o); })
+ .then(function () { send({ type: 'offer', targetPeerId: id, sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } }); })
+ .catch(function () { state('error', 'Could not start the call.'); });
+ }
+ return p;
+ }
+ function flush(p) { var c; while ((c = p.pending.shift())) p.pc.addIceCandidate(c).catch(function () {}); }
+
+ function onMessage(m) {
+ switch (m.type) {
+ case 'welcome':
+ myPeerId = m.peerId;
+ (m.participants || []).forEach(function (x) { people.set(x.peerId, x); if (x.peerId !== myPeerId) peer(x.peerId, true); });
+ state('in', localStream ? '' : 'No microphone: you can listen.'); names();
+ break;
+ case 'peer-joined': people.set(m.peerId, m); peer(m.peerId, false); names(); break;
+ case 'peer-left': people.delete(m.peerId); closePeer(m.peerId); names(); break;
+ case 'offer': {
+ var p = peers.get(m.fromPeerId) || peer(m.fromPeerId, false);
+ p.pc.setRemoteDescription(m.sdp).then(function () { flush(p); return p.pc.createAnswer(); })
+ .then(function (a) { return p.pc.setLocalDescription(a); })
+ .then(function () { send({ type: 'answer', targetPeerId: m.fromPeerId, sdp: { type: p.pc.localDescription.type, sdp: p.pc.localDescription.sdp } }); })
+ .catch(function () { state('error', 'Could not connect the call.'); });
+ break;
+ }
+ case 'answer': { var q = peers.get(m.fromPeerId); if (q) q.pc.setRemoteDescription(m.sdp).then(function () { flush(q); }).catch(function () {}); break; }
+ case 'ice-candidate': {
+ var r = peers.get(m.fromPeerId); if (!r || !m.candidate) break;
+ if (r.pc.remoteDescription) r.pc.addIceCandidate(m.candidate).catch(function () {}); else r.pending.push(m.candidate);
+ break;
+ }
+ case 'call-ended': case 'kicked': stop('ended', 'The call ended.'); break;
+ case 'error': stop('error', m.message || 'The call is not available.'); break;
+ default: break;
+ }
+ }
+
+ function stop(s, text) {
+ if (ended) return;
+ ended = true;
+ peers.forEach(function (p, id) { closePeer(id); });
+ people.clear();
+ if (ws) { try { ws.close(); } catch (e) { /* */ } ws = null; }
+ if (localStream) { localStream.getTracks().forEach(function (t) { t.stop(); }); localStream = null; }
+ state(s, text);
+ }
+
+ state('joining', 'Joining the call…');
+ var ice = fetch('/api/chat/ice-servers', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && Array.isArray(d.iceServers) && d.iceServers.length) iceServers = d.iceServers; }).catch(function () {});
+ var mic = navigator.mediaDevices && navigator.mediaDevices.getUserMedia
+ ? navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }).then(function (s) { if (ended) s.getTracks().forEach(function (t) { t.stop(); }); else localStream = s; }, function () {})
+ : Promise.resolve();
+ Promise.all([ice, mic]).then(function () {
+ if (ended) return;
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/call?channelId=' + encodeURIComponent(opts.channel));
+ ws.onopen = function () {
+ if (opts.visitorToken) send({ type: 'support-visitor', token: opts.visitorToken });
+ else if (opts.token) send({ type: 'auth-update', token: opts.token });
+ };
+ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMessage(m); };
+ ws.onclose = function () { stop('ended', myPeerId ? 'The call ended.' : 'Could not reach the call.'); };
+ });
+
+ return {
+ mute: function (on) { muted = !!on; if (localStream) localStream.getAudioTracks().forEach(function (t) { t.enabled = !muted; }); send({ type: 'mute', muted: muted }); },
+ leave: function () { stop('ended', 'You left the call.'); },
+ get muted() { return muted; },
+ };
+ }
+
+ window.OVSupportCall = { join: join };
+})();
@@ -239,6 +239,13 @@ body.oc-app-body { overflow: hidden; }
@@ -239,6 +239,13 @@ body.oc-app-body { overflow: hidden; }
.ib-actions { display: flex; gap: 6px; }
.ib-actions { display: flex; gap: 6px; }
.ib-actions button { padding: 6px 12px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .85rem; cursor: pointer; }
.ib-actions button { padding: 6px 12px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .85rem; cursor: pointer; }
.ib-actions button:hover { border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
.ib-actions button:hover { border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
+/* A voice call with the visitor: the Call button beside the actions, and the bar above the reply box while one is open. */
+.ib-call-btn { margin-left: 6px; padding: 6px 14px; border-radius: 10px; border: 0; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, #fff); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }
+.ib-call-btn[hidden], .ib-callbar[hidden], .ib-callbar button[hidden] { display: none; }
+.ib-callbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--oc-accent); background: var(--oc-panel); }
+.ib-call-text { flex: 1 1 200px; font-weight: 600; }
+.ib-callbar button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--oc-line); background: transparent; color: var(--oc-text); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }
+.ib-callbar .ib-call-end { border-color: transparent; background: #dc2626; color: #fff; }
.ib-sys { text-align: center; color: var(--oc-muted); font-size: .82rem; }
.ib-sys { text-align: center; color: var(--oc-muted); font-size: .82rem; }
.ib-who { margin: 0 0 2px; font-size: .74rem; font-weight: 700; color: var(--oc-muted); }
.ib-who { margin: 0 0 2px; font-size: .74rem; font-weight: 700; color: var(--oc-muted); }
.ib-av { flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 700; }
.ib-av { flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 700; }
@@ -74,7 +74,7 @@
@@ -74,7 +74,7 @@
if (frame) return;
if (frame) return;
frame = document.createElement('iframe');
frame = document.createElement('iframe');
frame.title = 'Chat';
frame.title = 'Chat';
- frame.allow = 'clipboard-write';
+ frame.allow = 'clipboard-write; microphone; autoplay'; // a voice call the team starts (the browser still asks)
frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1'
frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1'
: base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500)) + (identity ? '&id=1' : '');
: base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500)) + (identity ? '&id=1' : '');
frame.addEventListener('load', sendIdentity);
frame.addEventListener('load', sendIdentity);
@@ -59,6 +59,10 @@ const KICK_COOLDOWN_MS = 60 * 1000;
@@ -59,6 +59,10 @@ const KICK_COOLDOWN_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
const PUBLIC_CHANNEL_ID = 'public';
const MODES = ['mic', 'mic+cam', 'cam+mic'];
const MODES = ['mic', 'mic+cam', 'cam+mic'];
const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;
const ROOM_CHANNEL = /^room-([a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9]))$/;
+// A support call (server/inbox/inbox.js startCall): `support-<conversation id>`, open while one of the inbox's people
+// keeps it; the inbox's people join as themselves, the visitor with their visitor token (a support-visitor message).
+const SUPPORT_CHANNEL = /^support-(cnv_[0-9A-HJKMNP-TV-Z]{26})$/;
+const SUPPORT_MAX = 4;
class CallServer {
class CallServer {
constructor() {
constructor() {
@@ -140,7 +144,7 @@ class CallServer {
@@ -140,7 +144,7 @@ class CallServer {
async listChannels(viewer = null) {
async listChannels(viewer = null) {
const result = [];
const result = [];
for (const [id, ch] of this.channels) {
for (const [id, ch] of this.channels) {
- if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar
+ if (ch.roomSlug || ch.support) continue; // call rooms are listed as rooms (openvibe.chat), support calls in their inbox
if (ch.private && !await this._canSeePrivate(ch, viewer)) continue;
if (ch.private && !await this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const room = this.rooms.get(id);
const participants = [];
const participants = [];
@@ -179,6 +183,33 @@ class CallServer {
@@ -179,6 +183,33 @@ class CallServer {
if (this._notifyTimer.unref) this._notifyTimer.unref();
if (this._notifyTimer.unref) this._notifyTimer.unref();
}
}
+ /** Open a support call for a conversation (one of the inbox's people asked): → the channel id. */
+ startSupportCall({ inboxId, inboxName, convId, userId }) {
+ const id = `support-${convId}`;
+ if (!SUPPORT_CHANNEL.test(id)) throw new Error('not a conversation id');
+ if (!this.channels.has(id)) {
+ this.channels.set(id, {
+ id, name: `${inboxName}: support call`, mode: 'mic', createdBy: userId, streamId: null, permanent: false,
+ support: { inboxId, convId }, createdAt: Date.now(), maxParticipants: SUPPORT_MAX,
+ });
+ }
+ return id;
+ }
+
+ /** End a conversation's support call: everyone in it is told, the channel goes. → whether there was one. */
+ async endSupportCall(convId) {
+ const id = `support-${convId}`;
+ if (!this.channels.has(id)) return false;
+ await this.endCall(id, 'closed');
+ this.channels.delete(id);
+ this.callBans.delete(id);
+ await lifecycle.channelClosed(id, 'closed').catch(() => {});
+ return true;
+ }
+
+ /** Whether a conversation has a support call open. */
+ supportCallOpen(convId) { return this.channels.has(`support-${convId}`); }
+
/** The call room behind a `room-<slug>` channel id, or null. */
/** The call room behind a `room-<slug>` channel id, or null. */
async _roomOf(channelId) {
async _roomOf(channelId) {
const m = ROOM_CHANNEL.exec(String(channelId || ''));
const m = ROOM_CHANNEL.exec(String(channelId || ''));
@@ -283,7 +314,7 @@ class CallServer {
@@ -283,7 +314,7 @@ class CallServer {
return {
return {
peerId, username: info.user ? info.user.username : null,
peerId, username: info.user ? info.user.username : null,
anonId: info.anonId || null,
anonId: info.anonId || null,
- displayName: info.user ? (info.user.display_name || info.user.username) : info.anonId,
+ displayName: info.supportName || (info.user ? (info.user.display_name || info.user.username) : info.anonId),
userId: info.user ? info.user.id : null,
userId: info.user ? info.user.id : null,
avatarUrl: info.user ? info.user.avatar_url : null,
avatarUrl: info.user ? info.user.avatar_url : null,
profileColor: info.user ? info.user.profile_color : null,
profileColor: info.user ? info.user.profile_color : null,
@@ -294,6 +325,7 @@ class CallServer {
@@ -294,6 +325,7 @@ class CallServer {
speaking: info.speaking || false,
speaking: info.speaking || false,
nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null, badgeFX: cosmeticProfile.badgeFX || null,
nameFX: cosmeticProfile.nameFX || null, particleFX: cosmeticProfile.particleFX || null, hatFX: cosmeticProfile.hatFX || null, badgeFX: cosmeticProfile.badgeFX || null,
...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),
...(info.inRoom ? { roomRole: info.roomRole || null, canTalk: !info.listenOnly } : {}),
+ ...(info.supportRole ? { supportRole: info.supportRole } : {}),
};
};
}
}
@@ -360,7 +392,7 @@ class CallServer {
@@ -360,7 +392,7 @@ class CallServer {
// browser's upgrade is anonymous, and a private call or a members-only room must still let it
// browser's upgrade is anonymous, and a private call or a members-only room must still let it
// in. A socket refused while anonymous waits briefly for that message.
// in. A socket refused while anonymous waits briefly for that message.
user = await this._presentedUser(early, user);
user = await this._presentedUser(early, user);
- let admission = await this._admission(channel, resolvedId, user);
+ let admission = await this._admission(channel, resolvedId, user, early);
if (admission.error) {
if (admission.error) {
// Admission may have awaited Live while an auth-update arrived in the buffer. A newly
// Admission may have awaited Live while an auth-update arrived in the buffer. A newly
// presented identity (anonymous upgrade only: auth-update cannot switch an already
// presented identity (anonymous upgrade only: auth-update cannot switch an already
@@ -368,18 +400,19 @@ class CallServer {
@@ -368,18 +400,19 @@ class CallServer {
const presented = await this._presentedUser(early, user);
const presented = await this._presentedUser(early, user);
if (presented && presented.id !== (user && user.id)) {
if (presented && presented.id !== (user && user.id)) {
user = presented;
user = presented;
- admission = await this._admission(channel, resolvedId, user);
+ admission = await this._admission(channel, resolvedId, user, early);
}
}
}
}
if (admission.error && !user) {
if (admission.error && !user) {
await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);
await this._firstAuthMessage(ws, FIRST_TOKEN_WAIT_MS);
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.readyState !== WebSocket.OPEN) return;
user = await this._presentedUser(early, user);
user = await this._presentedUser(early, user);
- if (user) admission = await this._admission(channel, resolvedId, user);
+ if (user || channel.support) admission = await this._admission(channel, resolvedId, user, early);
}
}
if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }
if (admission.error) { ws.send(JSON.stringify({ type: 'error', message: admission.error })); ws.close(); return; }
const roomAccess = admission.roomAccess;
const roomAccess = admission.roomAccess;
const listenOnly = !!(roomAccess && !roomAccess.talk);
const listenOnly = !!(roomAccess && !roomAccess.talk);
+ const support = admission.support || null;
const peerId = this._generatePeerId();
const peerId = this._generatePeerId();
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
@@ -425,6 +458,7 @@ class CallServer {
@@ -425,6 +458,7 @@ class CallServer {
const clientInfo = {
const clientInfo = {
ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,
ws, user, anonId, ip, peerId, identity, muted: listenOnly, cameraOff: true, forceMuted: listenOnly, forceCameraOff: listenOnly, speaking: false,
isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),
isChannelCreator, isStreamer, listenOnly, inRoom: !!channel.roomSlug, roomRole: roomAccess ? roomAccess.role : null, _msgCount: 0, _msgResetTime: Date.now(),
+ ...(support ? { supportRole: support.role, supportName: support.name } : {}),
};
};
this.clients.set(ws, { channelId: resolvedId, peerId });
this.clients.set(ws, { channelId: resolvedId, peerId });
room.set(peerId, clientInfo);
room.set(peerId, clientInfo);
@@ -484,8 +518,9 @@ class CallServer {
@@ -484,8 +518,9 @@ class CallServer {
}
}
/** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */
/** Whether `user` may enter `channel`: { error } or { roomAccess } (null outside call rooms). */
- async _admission(channel, channelId, user) {
+ async _admission(channel, channelId, user, early = []) {
if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };
if (channel.private && !await this._canSeePrivate(channel, user)) return { error: 'This is a private call' };
+ if (channel.support) return await this._supportAdmission(channel, user, early);
// A call room: the room's roles decide who is in and who talks.
// A call room: the room's roles decide who is in and who talks.
if (!channel.roomSlug) return { roomAccess: null };
if (!channel.roomSlug) return { roomAccess: null };
const room = await this._roomOf(channelId);
const room = await this._roomOf(channelId);
@@ -514,12 +549,37 @@ class CallServer {
@@ -514,12 +549,37 @@ class CallServer {
return next;
return next;
}
}
- /** Resolves on the next auth-update (buffered by then), close, or timeout. */
+ /**
+ * A support call's door: one of the inbox's people (signed in) joins as themselves; the visitor shows their visitor
+ * token in a support-visitor message and joins under the name they gave (else "Visitor"). Anyone else: not found.
+ */
+ async _supportAdmission(channel, user, early) {
+ const inboxes = require('../inbox/inbox');
+ const inbox = await inboxes.get(channel.support.inboxId);
+ if (!inbox) return { error: 'Voice channel not found' };
+ if (user) {
+ const a = await inboxes.access(inbox, user).catch(() => null);
+ if (a && a.role) return { roomAccess: null, support: { role: 'agent', name: null } };
+ }
+ let token = null;
+ for (const data of early) {
+ let msg;
+ try { msg = JSON.parse(data); } catch { continue; }
+ if (msg?.type === 'support-visitor' && typeof msg.token === 'string') { token = msg.token; break; }
+ }
+ const conv = token ? await inboxes.conversation(inbox, channel.support.convId).catch(() => null) : null;
+ if (conv && conv.visitor_token_hash && conv.visitor_token_hash === inboxes.hashToken(token)) {
+ return { roomAccess: null, support: { role: 'visitor', name: conv.visitor_name || 'Visitor' } };
+ }
+ return { error: 'Voice channel not found' };
+ }
+
+ /** Resolves on the next auth-update or support-visitor message (buffered by then), close, or timeout. */
_firstAuthMessage(ws, ms) {
_firstAuthMessage(ws, ms) {
return new Promise((resolve) => {
return new Promise((resolve) => {
const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };
const done = () => { clearTimeout(timer); ws.off('message', onMessage); ws.off('close', done); resolve(); };
const onMessage = (data) => {
const onMessage = (data) => {
- try { if (JSON.parse(data)?.type === 'auth-update') done(); } catch { /* wait */ }
+ try { const t = JSON.parse(data)?.type; if (t === 'auth-update' || t === 'support-visitor') done(); } catch { /* wait */ }
};
};
const timer = setTimeout(done, ms);
const timer = setTimeout(done, ms);
ws.on('message', onMessage);
ws.on('message', onMessage);
@@ -194,11 +194,14 @@ async function removeMember(inbox, user, userId) {
@@ -194,11 +194,14 @@ async function removeMember(inbox, user, userId) {
// ── Conversations and messages ─────────────────────────────────────────────────────────────────────────────────────
// ── Conversations and messages ─────────────────────────────────────────────────────────────────────────────────────
+/** Whether a conversation has a voice call open now (the call server keeps calls in memory). */
+function callOpen(id) { try { return require('../calls/call-server').supportCallOpen(id); } catch { return false; } }
+
const convShape = (c) => c && ({
const convShape = (c) => c && ({
id: c.id, inbox_id: c.inbox_id, status: c.status, visitor_name: c.visitor_name || null, page_url: c.page_url || null,
id: c.id, inbox_id: c.inbox_id, status: c.status, visitor_name: c.visitor_name || null, page_url: c.page_url || null,
assignee_id: c.assignee_id ? Number(c.assignee_id) : null, agent_unread: Number(c.agent_unread) || 0,
assignee_id: c.assignee_id ? Number(c.assignee_id) : null, agent_unread: Number(c.agent_unread) || 0,
rating: c.rating == null ? null : Number(c.rating), rating_comment: c.rating_comment || null, account: c.visitor_user_id != null,
rating: c.rating == null ? null : Number(c.rating), rating_comment: c.rating_comment || null, account: c.visitor_user_id != null,
- verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,
+ verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null, call: callOpen(c.id),
created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,
created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,
});
});
// account: a visitor's message written signed in to OpenVibe, whose OpenVibe.Pics links show as pictures (anyone else's stay links).
// account: a visitor's message written signed in to OpenVibe, whose OpenVibe.Pics links show as pictures (anyone else's stay links).
@@ -468,10 +471,42 @@ async function setStatus(inbox, conv, user, status, deps = {}) {
@@ -468,10 +471,42 @@ async function setStatus(inbox, conv, user, status, deps = {}) {
if (!['open', 'resolved', 'bot'].includes(status)) fail(422, 'inbox.status', 'A conversation is open, resolved, or back with Vibe');
if (!['open', 'resolved', 'bot'].includes(status)) fail(422, 'inbox.status', 'A conversation is open, resolved, or back with Vibe');
if (status === 'bot' && !Number(inbox.ai_enabled)) fail(409, 'inbox.ai_off', 'Vibe is off for this inbox');
if (status === 'bot' && !Number(inbox.ai_enabled)) fail(409, 'inbox.ai_off', 'Vibe is off for this inbox');
const next = await setStatusRow(conv, status, deps);
const next = await setStatusRow(conv, status, deps);
- if (status === 'resolved') await addMessage(conv, { author: 'system', body: 'This conversation was marked resolved. Write again any time to reopen it.' }, deps);
+ if (status === 'resolved') {
+ await endCall(inbox, conv, user, deps).catch(() => {});
+ await addMessage(conv, { author: 'system', body: 'This conversation was marked resolved. Write again any time to reopen it.' }, deps);
+ }
return convShape(next);
return convShape(next);
}
}
+// ── Calls (docs/platform.md: call escalation) ──────────────────────────────────────────────────────────────────────
+// One of the inbox's people starts a voice call from a conversation (server/calls/call-server.js support-<cid>); the
+// visitor's panel offers to join it, peer to peer, no account needed. Either side ends it; resolving ends it too.
+const nameOf = (user) => (user && (user.display_name || user.username)) || 'Someone';
+
+async function startCall(inbox, conv, user, deps = {}) {
+ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people call visitors');
+ if (conv.status === 'resolved') fail(409, 'inbox.resolved', 'Reopen the conversation to call');
+ if (!config.calls.enabled && !deps.calls) fail(409, 'inbox.calls_off', 'Calls are not available here right now');
+ const calls = deps.calls || require('../calls/call-server');
+ const already = calls.supportCallOpen(conv.id);
+ const channel = calls.startSupportCall({ inboxId: inbox.id, inboxName: inbox.name, convId: conv.id, userId: user.id });
+ if (!already) {
+ await addMessage(conv, { author: 'system', body: `${nameOf(user)} started a voice call.` }, deps);
+ broadcaster(deps)(inbox.id, conv.id, { type: 'inbox_call', inbox: inbox.id, conversation: conv.id, state: 'ringing', channel, by: nameOf(user) });
+ }
+ return { channel, conversation: convShape(conv) };
+}
+
+/** user: one of the inbox's people, or null for the visitor (their panel's "Not now" or hang up). */
+async function endCall(inbox, conv, user, deps = {}) {
+ if (user && !(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people end calls');
+ const calls = deps.calls || require('../calls/call-server');
+ if (!(await calls.endSupportCall(conv.id))) return { ended: false };
+ await addMessage(conv, { author: 'system', body: user ? `${nameOf(user)} ended the call.` : 'The visitor ended the call.' }, deps);
+ broadcaster(deps)(inbox.id, conv.id, { type: 'inbox_call', inbox: inbox.id, conversation: conv.id, state: 'ended' });
+ return { ended: true };
+}
+
/** One of the inbox's people (or nobody: null) answers this conversation; any of them may assign it. */
/** 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 = {}) {
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');
if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people assign conversations');
@@ -590,5 +625,5 @@ module.exports = {
@@ -590,5 +625,5 @@ 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, assign, 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,
+ startCall, endCall, aiAnswer, inboxPrompt, _reset,
};
};
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }
* POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }
* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)
* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)
* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)
* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)
+ * POST /call/end { token } → { ended }: the visitor declines or hangs up the conversation's voice call
* POST /picture the body is a picture; X-Visitor-Token, an optional line in X-Caption → 201 { conversation, message,
* POST /picture the body is a picture; X-Visitor-Token, an optional line in X-Caption → 201 { conversation, message,
* picture } (signed in to OpenVibe only: it goes to OpenVibe.Pics as them; 403 inbox.sign_in_to_attach)
* picture } (signed in to OpenVibe only: it goes to OpenVibe.Pics as them; 403 inbox.sign_in_to_attach)
*
*
@@ -38,6 +39,8 @@
@@ -38,6 +39,8 @@
* 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, assignee_id: a person's id | null }
* PATCH /:id/conversations/:cid { status: open | resolved | bot, assignee_id: a person's id | null }
+ * POST /:id/conversations/:cid/call → { channel }: a voice call with the visitor (/ws/call?channelId=support-<cid>)
+ * DELETE /:id/conversations/:cid/call → { ended }
* POST /:id/conversations/:cid/read
* POST /:id/conversations/:cid/read
*/
*/
const express = require('express');
const express = require('express');
@@ -102,6 +105,14 @@ widget.post('/messages', postLimit, async (req, res) => {
@@ -102,6 +105,14 @@ widget.post('/messages', postLimit, async (req, res) => {
const inbox = await inboxOr404(req, res); if (!inbox) return;
const inbox = await inboxOr404(req, res); if (!inbox) return;
try { res.status(201).json(await inboxes.visitorPost(inbox, tokenOf(req), { ...(req.body || {}), account: await accountOf(req) })); } catch (err) { send(res, err); }
try { res.status(201).json(await inboxes.visitorPost(inbox, tokenOf(req), { ...(req.body || {}), account: await accountOf(req) })); } catch (err) { send(res, err); }
});
});
+widget.post('/call/end', postLimit, async (req, res) => {
+ const inbox = await inboxOr404(req, res); if (!inbox) return;
+ try {
+ const v = await inboxes.visit(inbox, tokenOf(req));
+ if (!v.conversation) return res.json({ ended: false });
+ res.json(await inboxes.endCall(inbox, await inboxes.conversation(inbox, v.conversation.id), null));
+ } catch (err) { send(res, err); }
+});
widget.post('/handoff', postLimit, async (req, res) => {
widget.post('/handoff', postLimit, async (req, res) => {
const inbox = await inboxOr404(req, res); if (!inbox) return;
const inbox = await inboxOr404(req, res); if (!inbox) return;
try { res.json({ conversation: await inboxes.handoff(inbox, tokenOf(req)) }); } catch (err) { send(res, err); }
try { res.json({ conversation: await inboxes.handoff(inbox, tokenOf(req)) }); } catch (err) { send(res, err); }
@@ -288,6 +299,15 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (
@@ -288,6 +299,15 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (
const conv = await agentConversation(req, res); if (!conv) return;
const conv = await agentConversation(req, res); if (!conv) return;
try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, req.body && req.body.message) }); } catch (err) { send(res, err); }
try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, req.body && req.body.message) }); } catch (err) { send(res, err); }
});
});
+agents.post('/:id/conversations/:cid/call', requireAuth, manageLimit, async (req, res) => {
+ const conv = await agentConversation(req, res); if (!conv) return;
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Calls are made with your own sign-in', code: 'inbox.person_only' });
+ try { noStore(res).json(await inboxes.startCall(req.inbox, conv, req.user)); } catch (err) { send(res, err); }
+});
+agents.delete('/:id/conversations/:cid/call', requireAuth, manageLimit, async (req, res) => {
+ const conv = await agentConversation(req, res); if (!conv) return;
+ try { res.json(await inboxes.endCall(req.inbox, conv, req.user)); } catch (err) { send(res, err); }
+});
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;
const b = req.body || {};
const b = req.body || {};
@@ -43,6 +43,7 @@ function embedHtml(inbox) {
@@ -43,6 +43,7 @@ function embedHtml(inbox) {
<meta name="robots" content="noindex, nofollow">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="${asset('embed.css')}">
<link rel="stylesheet" href="${asset('embed.css')}">
<script src="${asset('vibe-md.js')}" defer></script>
<script src="${asset('vibe-md.js')}" defer></script>
+<script src="${asset('support-call.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
</head>
</head>
<body style="--accent:${esc(inbox.color)}">
<body style="--accent:${esc(inbox.color)}">
@@ -145,7 +146,7 @@ ${list}
@@ -145,7 +146,7 @@ ${list}
const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
const actions = current ? `${assignForm}<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
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>${current.status !== 'resolved' && config.calls.enabled ? '<button type="button" class="ib-call-btn" id="ib-call" hidden>Call</button>' : ''}` : '';
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) : ''}">
<aside class="vb-rail" id="vb-rail" aria-label="Conversations">
<aside class="vb-rail" id="vb-rail" aria-label="Conversations">
<div class="vb-rail-top"><a class="vb-new" href="/inbox/${esc(inbox.id)}/settings"><span class="ib-dot" style="background:${esc(inbox.color)}"></span><span>${esc(inbox.name)}</span></a>
<div class="vb-rail-top"><a class="vb-new" href="/inbox/${esc(inbox.id)}/settings"><span class="ib-dot" style="background:${esc(inbox.color)}"></span><span>${esc(inbox.name)}</span></a>
@@ -162,14 +163,14 @@ ${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 're
@@ -162,14 +163,14 @@ ${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 're
${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}</p>` : ''}${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`
${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}</p>` : ''}${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`
: `<div class="vb-hello"><h1>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h1><p class="vb-sub">${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`}
: `<div class="vb-hello"><h1>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h1><p class="vb-sub">${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`}
</div>
</div>
-${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
+${current ? `<div class="vb-dock"><div class="ib-callbar" id="ib-callbar" role="group" aria-label="Voice call" hidden><span class="ib-call-text" id="ib-call-text"></span><button type="button" id="ib-call-join" hidden>Join the call</button><button type="button" id="ib-call-mute" aria-pressed="false" hidden>Mute</button><button type="button" class="ib-call-end" id="ib-call-end">End call</button></div><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>
<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>
${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">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</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>` : ''}
${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">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</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>
</section>
</div>`;
</div>`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, app: true, appScript: 'inbox.js', active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
- page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
+ actor, app: true, appScript: 'inbox.js', scripts: ['support-call.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
+ page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Support calls (server/inbox/inbox.js startCall, server/calls/call-server.js support-<cid>): one of an inbox's people
+ * starts a voice call from a conversation; the visitor joins with their visitor token, the inbox's people as themselves,
+ * anyone else is refused; the visitor declining or hanging up ends it for everyone, and so does resolving; the panel
+ * loads the call client and the inbox page shows the Call button.
+ */
+const assert = require('assert');
+const WebSocket = require('ws');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('support calls');
+let h, ann, bob, cat, inbox, token, cid;
+const agentsApi = (method, path, u, body) => h.http(method, `/api/chat/inboxes${path}`, { token: u && u.token, body });
+const widget = (path, body) => fetch(`${h.base}/api/chat/widget/inbox/${inbox.id}${path}`, { method: body ? 'POST' : 'GET', headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then((r) => r.json());
+
+/** Join /ws/call: resolves with the socket and its first welcome or error. */
+function join(channelId, { bearer = null, first = null } = {}) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${h.port}/ws/call?channelId=${encodeURIComponent(channelId)}`, { headers: { origin: 'https://openvibe.live', 'cf-connecting-ip': `203.0.113.${Math.floor(Math.random() * 200) + 20}`, ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) } });
+ const all = [];
+ ws.all = all;
+ ws.wait = (pred, ms = 3000) => new Promise((res, rej) => {
+ const hit = all.find(pred); if (hit) return res(hit);
+ const timer = setTimeout(() => rej(new Error(`timed out (got ${JSON.stringify(all.map((m) => m.type))})`)), ms);
+ ws.on('message', function on(d) { let m; try { m = JSON.parse(d.toString()); } catch { return; } if (pred(m)) { clearTimeout(timer); ws.off('message', on); res(m); } });
+ });
+ ws.on('message', (d) => { try { all.push(JSON.parse(d.toString())); } catch { /* */ } });
+ ws.on('open', async () => {
+ if (first) ws.send(JSON.stringify(first));
+ try { ws.first = await ws.wait((m) => m.type === 'welcome' || m.type === 'error'); resolve(ws); } catch (e) { reject(e); }
+ });
+ ws.on('error', reject);
+ });
+}
+
+t('boot (calls on)', async () => {
+ h = await boot({ env: { CHAT_CALLS: '1', CHAT_INBOX_POSTS_PER_MINUTE: '500' } });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ inbox = (await agentsApi('POST', '', ann, { name: 'Acme', ai_enabled: false })).body.inbox;
+ await agentsApi('POST', `/${inbox.id}/members`, ann, { username: 'bob' });
+ token = (await widget('/visit', {})).token;
+ cid = (await widget('/messages', { token, message: 'Can someone talk me through the setup?', name: 'Dana' })).conversation.id;
+});
+
+t('one of the people starts a call; the visitor and the people join, nobody else does', async () => {
+ const before = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });
+ assert.strictEqual(before.first.type, 'error', 'no call yet: nothing to join');
+ before.close();
+ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, cat)).status, 404, 'not one of the people');
+ const started = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob);
+ assert.strictEqual(started.status, 200, JSON.stringify(started.body));
+ assert.strictEqual(started.body.channel, `support-${cid}`);
+ const seen = await widget(`/messages?token=${token}`);
+ assert.strictEqual(seen.conversation.call, true, 'the panel learns there is a call');
+ assert.ok(seen.messages.some((m) => m.author === 'system' && m.body === 'Bob started a voice call.'), JSON.stringify(seen.messages.map((m) => m.body)));
+
+ const visitor = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });
+ assert.strictEqual(visitor.first.type, 'welcome', JSON.stringify(visitor.first));
+ const agent = await join(`support-${cid}`, { bearer: bob.token });
+ assert.strictEqual(agent.first.type, 'welcome');
+ const dana = agent.first.participants.find((p) => p.supportRole === 'visitor');
+ assert.ok(dana && dana.displayName === 'Dana', 'the people see the visitor by the name they gave');
+ assert.ok(agent.first.participants.some((p) => p.supportRole === 'agent'));
+ const stranger = await join(`support-${cid}`, { bearer: cat.token });
+ assert.strictEqual(stranger.first.type, 'error', 'someone else is refused');
+ const forged = await join(`support-${cid}`, { first: { type: 'support-visitor', token: 'x'.repeat(32) } });
+ assert.strictEqual(forged.first.type, 'error', 'a wrong visitor token too');
+ for (const ws of [stranger, forged]) ws.close();
+
+ // The visitor hangs up: everyone is told, the channel goes, the thread says so.
+ assert.deepStrictEqual(await widget('/call/end', { token }), { ended: true });
+ await Promise.all([visitor.wait((m) => m.type === 'call-ended'), agent.wait((m) => m.type === 'call-ended')]);
+ assert.ok((await widget(`/messages?token=${token}`)).messages.some((m) => m.body === 'The visitor ended the call.'));
+ assert.strictEqual((await widget(`/messages?token=${token}`)).conversation.call, false);
+ for (const ws of [visitor, agent]) ws.close();
+});
+
+t('resolving ends a call; a resolved conversation cannot be called; the pages carry the call', async () => {
+ await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob);
+ const visitor = await join(`support-${cid}`, { first: { type: 'support-visitor', token } });
+ assert.strictEqual(visitor.first.type, 'welcome');
+ const page = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((r) => r.text());
+ assert.ok(page.includes('id="ib-call"') && page.includes('id="ib-callbar"') && page.includes('support-call.js') && page.includes('"call":true'));
+ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${cid}`, bob, { status: 'resolved' })).status, 200);
+ await visitor.wait((m) => m.type === 'call-ended');
+ visitor.close();
+ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/call`, bob)).body.code, 'inbox.resolved');
+ const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((r) => r.text());
+ assert.ok(panel.includes('support-call.js'), 'the panel loads the call client');
+ const widgetJs = await fetch(`${h.base}/widget.js`).then((r) => r.text());
+ assert.ok(widgetJs.includes("frame.allow = 'clipboard-write; microphone; autoplay'"), 'the panel may ask for the microphone');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });