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)

OP OpenVibers committed 19 days ago · 5ca533ccc3 · 13 files, +457 −17

Changes

ddiff --git a/README.md b/README.md
iindex 506d6ac..790a07a 100644
---- a/README.md
++++ b/README.md
@@@ -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 finished
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
ddiff --git a/docs/platform.md b/docs/platform.md
iindex ce8e10f..04efe72 100644
---- a/docs/platform.md
++++ b/docs/platform.md
@@@ -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 inbox
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`,
`createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
ddiff --git a/public/web/embed.css b/public/web/embed.css
iindex eaa0967..7abe2b2 100644
---- a/public/web/embed.css
++++ b/public/web/embed.css
@@@ -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 */
ddiff --git a/public/web/embed.js b/public/web/embed.js
iindex ce19ef0..b6c4600 100644
---- a/public/web/embed.js
++++ b/public/web/embed.js
@@@ -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.'); })
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex 9dddb62..fd6daaf 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -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(); });
++ }
})();
ddiff --git a/public/web/support-call.js b/public/web/support-call.js
nnew file mode 100644
iindex 0000000..b21ee8b
---- /dev/null
++++ b/public/web/support-call.js
@@@ -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 };
++})();
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex fb03b9e..f69198c 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -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; }
ddiff --git a/public/web/widget.js b/public/web/widget.js
iindex 5f8db3e..472183e 100644
---- a/public/web/widget.js
++++ b/public/web/widget.js
@@@ -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);
ddiff --git a/server/calls/call-server.js b/server/calls/call-server.js
iindex 53db043..2299c4f 100644
---- a/server/calls/call-server.js
++++ b/server/calls/call-server.js
@@@ -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 inbox
if (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);
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex 3c306e5..a13d89c 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -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,
};
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex 6a2d202..8514e35 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -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 || {};
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 6f2ed85..3022722 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -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">&#128206;</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">&#8593;</button></form><p class="vb-fine">Replying takes the conversation from Vibe. <label class="ib-tr-pick" hidden>Translate my reply into <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label></p></div>` : ''}
</section>
</div>`;
await page(req, res, 200, {
-- 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 })) },
});
});
ddiff --git a/test/support-calls.test.js b/test/support-calls.test.js
nnew file mode 100644
iindex 0000000..8dc02c6
---- /dev/null
++++ b/test/support-calls.test.js
@@@ -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(); });