@calloop/agent-sdk 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,483 +1,483 @@
1
- // An agent's connection to Calloop's agent gateway (docs/agent-protocol.md).
2
- // Owns the socket, the agent's identity and ICE servers (session:ready),
3
- // availability, queue snapshots and this agent's calls — and keeps them right
4
- // across reconnects: every (re)connect resyncs from calls:list.
5
- //
6
- // Events:
7
- // 'ready' (session) connected and identified; also after each reconnect
8
- // 'sessionRefreshed' (session) new ICE/TURN credentials, fetched before they expire
9
- // 'incoming' (call) a call is offered to this agent — ring
10
- // 'elsewhere' (call) this agent's call is active on another device
11
- // 'callState' (call, state, previous)
12
- // 'callEnded' (call, { reason, ... })
13
- // 'availability' ({ availability, reason? }) this agent
14
- // 'team' ({ agentId, availability, reason? }) any agent of the tenant
15
- // 'queue' (snapshot) one per queue, on change and on sync
16
- // 'counters' (counters) supervisors: the tenant's live counts, pushed at most every 2 s
17
- // 'error' ({ callId, code, message } | Error)
18
- // 'disconnected' (reason)
19
- // 'closed' ({ reason }) Calloop ended the connection for good (e.g. its signing key
20
- // was revoked) and it won't reconnect: close() and connect again
21
- // Supervisors only (agent.board = the tenant's live calls):
22
- // 'board' (board) the whole board, after each (re)sync
23
- // 'boardCall' (view) a call appeared or changed
24
- // 'boardCallEnded' (view, { terminationReason, terminatedBy })
25
- import { io as defaultIo } from 'socket.io-client';
26
- import { Emitter } from './emitter.js';
27
- import { Call } from './Call.js';
28
- import { Monitor } from './Monitor.js';
29
-
30
- const PROTOCOL = 1;
31
- const TERMINAL = new Set(['TERMINATED', 'FAILED', 'CANCELLED']);
32
- // Call errors after which the call can't go on here.
33
- const ENDING_ERRORS = new Set(['ACCEPT_FAILED', 'CALL_ALREADY_ENDED', 'AGENT_MEDIA_NOT_READY', 'CALL_INITIATION_FAILED', 'PROVIDER_TRIGGER_FAILED']);
34
- // ICE/TURN credentials are refreshed at this share of their lifetime, never sooner than MIN_REFRESH_MS.
35
- const REFRESH_AT = 0.8;
36
- const MIN_REFRESH_MS = 60_000;
37
-
38
- function defaultDeviceId() {
39
- const KEY = 'calloop.deviceId';
40
- try {
41
- const stored = globalThis.localStorage?.getItem(KEY);
42
- if (stored) return stored;
43
- const id = globalThis.crypto?.randomUUID?.() ?? `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`;
44
- globalThis.localStorage?.setItem(KEY, id);
45
- return id;
46
- } catch {
47
- return `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`;
48
- }
49
- }
50
-
51
- // A URL that isn't TLS, to a host other than this machine.
52
- function insecureUrl(url) {
53
- try {
54
- const u = new URL(url);
55
- return !['https:', 'wss:'].includes(u.protocol) && !['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname);
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
- export class CalloopAgent extends Emitter {
62
- /**
63
- * @param {object} opts
64
- * url Calloop base URL (https://api.calloop.net)
65
- * token an agent JWT, or
66
- * getToken async () => JWT — called on every (re)connect (tokens are short-lived)
67
- * deviceId stable id for this device (default: one kept in localStorage)
68
- * webrtc { RTCPeerConnection, MediaStream } (default: the browser's)
69
- * getUserMedia async () => MediaStream for the microphone (default: navigator.mediaDevices)
70
- * io socket.io-client's io (default: the bundled one)
71
- * purpose 'session' (default): the agent's main connection. Anything else is
72
- * an auxiliary connection (e.g. one that only declines a call): no
73
- * presence, no redelivered calls, and no calls:sync on connect
74
- */
75
- constructor({ url, token, getToken, deviceId, webrtc, getUserMedia, io = defaultIo, purpose = 'session' } = {}) {
76
- super();
77
- if (!url) throw new Error('url is required');
78
- if (!token && !getToken) throw new Error('token or getToken is required');
79
- // The agent token and the call's signalling travel over this URL: plain http/ws
80
- // exposes them to anyone on the network, so it's for a local server only.
81
- if (insecureUrl(url)) console.warn(`[calloop] ${url} isn't https: the agent token and calls travel unencrypted. Use https:// outside local development.`);
82
- this.url = url;
83
- this.purpose = purpose;
84
- this.deviceId = deviceId ?? defaultDeviceId();
85
- this.webrtc = {
86
- RTCPeerConnection: webrtc?.RTCPeerConnection ?? globalThis.RTCPeerConnection,
87
- MediaStream: webrtc?.MediaStream ?? globalThis.MediaStream,
88
- };
89
- if (!this.webrtc.RTCPeerConnection) throw new Error('No RTCPeerConnection: pass webrtc.RTCPeerConnection');
90
- this._getUserMedia = getUserMedia ?? (() => globalThis.navigator.mediaDevices.getUserMedia({
91
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
92
- }));
93
- this._getToken = getToken ?? (async () => token);
94
-
95
- this.session = null;
96
- this.agent = null;
97
- this.iceServers = [];
98
- this.availability = null;
99
- this.closedReason = null; // set when Calloop ended the connection for good ('closed')
100
- this.calls = new Map(); // callId → Call
101
- this.queues = new Map(); // queueId → snapshot
102
- this.team = new Map(); // agentId → { agentId, availability, reason?, updatedAt? }
103
- this.board = new Map(); // supervisors: callId → the tenant's live calls
104
- this.monitors = new Map(); // supervisors: callId → Monitor
105
- this._resolved = new Set(); // calls already over — redeliveries are ignored
106
- this._refreshTimer = null;
107
- this._refreshing = false;
108
-
109
- this.socket = io(url, {
110
- transports: ['websocket'],
111
- auth: (cb) => {
112
- Promise.resolve(this._getToken())
113
- .then((t) => cb({ token: t, device_id: this.deviceId, protocol: PROTOCOL, purpose: this.purpose }))
114
- .catch(() => cb({ token: null, device_id: this.deviceId, protocol: PROTOCOL, purpose: this.purpose }));
115
- },
116
- });
117
- this.ready = new Promise((resolve, reject) => {
118
- this._resolveReady = resolve;
119
- this._rejectReady = reject;
120
- });
121
- this.#listen();
122
- }
123
-
124
- // ── Agent actions ─────────────────────────────────────────────────────────
125
-
126
- /** 'AVAILABLE' or 'OFFLINE' (ON_CALL is Calloop's). Supervisors may pass another agent's id. */
127
- setAvailability(availability, { agentId } = {}) {
128
- this._send('agent:availability:set', agentId != null ? { availability, agentId } : { availability });
129
- }
130
-
131
- /** Re-read this agent's calls (supervisors: the tenant's) and the queue snapshots. */
132
- sync() {
133
- this._send('calls:sync');
134
- }
135
-
136
- // ── The board (docs/agent-protocol.md → Board) ────────────────────────────
137
-
138
- /**
139
- * Narrow this connection's board to some lines, queues or agents (any of
140
- * them): { channelIds?, queueIds?, agentIds? }; {} = the whole tenant.
141
- * Resolves with the filter applied.
142
- */
143
- async subscribeBoard(filter = {}) {
144
- return (await this._ask('board:subscribe', filter)).filter ?? null;
145
- }
146
-
147
- /** Stop receiving the board on this connection. */
148
- async unsubscribeBoard() {
149
- await this._ask('board:unsubscribe', {});
150
- }
151
-
152
- /** A page of the live calls: { channelIds?, queueIds?, agentIds?, cursor?, limit? } → { calls, nextCursor }. */
153
- boardCalls(query = {}) {
154
- return this._ask('board:calls', query);
155
- }
156
-
157
- /** Supervisors: the tenant's counters now (they are also pushed as 'counters'). */
158
- boardCounters() {
159
- return this._ask('board:counters', {});
160
- }
161
-
162
- /**
163
- * Start an outbound call the consumer created (POST /v1/tenants/{t}/calls):
164
- * connects this agent's leg; Calloop then dials the customer.
165
- */
166
- async startOutbound(callId, { stream } = {}) {
167
- const call = new Call(this, { callId, direction: 'OUTBOUND' }, 'dialing');
168
- this.calls.set(String(callId), call);
169
- await call._start(stream);
170
- return call;
171
- }
172
-
173
- call(callId) {
174
- return this.calls.get(String(callId)) ?? null;
175
- }
176
-
177
- get isSupervisor() {
178
- return this.agent?.role === 'SUPERVISOR';
179
- }
180
-
181
- /**
182
- * Supervisors: listen to a call, then setMode('whisper' | 'barge').
183
- * stream: the supervisor's microphone (default: getUserMedia).
184
- */
185
- async monitor(callId, { stream } = {}) {
186
- if (!this.isSupervisor) throw new Error('Only supervisors can monitor calls');
187
- const id = String(callId);
188
- const existing = this.monitors.get(id);
189
- if (existing && existing.state !== 'ended') return existing;
190
- const monitor = new Monitor(this, callId);
191
- this.monitors.set(id, monitor);
192
- try {
193
- await monitor._start(stream);
194
- } catch (err) {
195
- monitor._end('failed');
196
- throw err;
197
- }
198
- return monitor;
199
- }
200
-
201
- /** Supervisors: move a call to an agent ({ agentId }) or into a queue ({ queueId }). */
202
- transferCall(callId, target) {
203
- if (!target?.agentId && !target?.queueId) throw new Error('transfer needs { agentId } or { queueId }');
204
- this._send('call:transfer', { callId, ...target });
205
- }
206
-
207
- /** New ICE/TURN credentials now (also done automatically before they expire). */
208
- refreshSession() {
209
- this._refreshing = true;
210
- this._send('session:refresh');
211
- }
212
-
213
- close() {
214
- clearTimeout(this._refreshTimer);
215
- for (const call of this.calls.values()) call._closePeer();
216
- for (const monitor of [...this.monitors.values()]) monitor._end('closed');
217
- this.socket.close();
218
- }
219
-
220
- // ── Internals ─────────────────────────────────────────────────────────────
221
-
222
- _send(event, payload) {
223
- this.socket.emit(event, payload);
224
- }
225
-
226
- // A request answered through the socket's acknowledgement.
227
- _ask(event, payload, timeoutMs = 10000) {
228
- return new Promise((resolve, reject) => {
229
- const timer = setTimeout(() => reject(new Error(`${event} timed out`)), timeoutMs);
230
- this.socket.emit(event, payload, (res) => {
231
- clearTimeout(timer);
232
- if (res?.error) reject(Object.assign(new Error(res.error.message), { code: res.error.code }));
233
- else resolve(res);
234
- });
235
- });
236
- }
237
-
238
- async _getMicrophone() {
239
- return this._getUserMedia();
240
- }
241
-
242
- _forget(call, details) {
243
- this.calls.delete(String(call.id));
244
- this._resolved.add(String(call.id));
245
- this.emit('callEnded', call, details);
246
- }
247
-
248
- _forgetMonitor(monitor) {
249
- if (this.monitors.get(String(monitor.callId)) === monitor) this.monitors.delete(String(monitor.callId));
250
- }
251
-
252
- #scheduleRefresh() {
253
- clearTimeout(this._refreshTimer);
254
- const expiresAt = Date.parse(this.session?.iceServersExpireAt ?? '');
255
- if (!Number.isFinite(expiresAt)) return; // static credentials: nothing to refresh
256
- const delay = Math.max(MIN_REFRESH_MS, (expiresAt - Date.now()) * REFRESH_AT);
257
- this._refreshTimer = setTimeout(() => this.refreshSession(), Math.min(delay, 2 ** 31 - 1));
258
- this._refreshTimer.unref?.();
259
- }
260
-
261
- #isMine(payload) {
262
- const me = this.agent?.id;
263
- if (me == null) return false;
264
- if (payload.agentId != null) return String(payload.agentId) === String(me);
265
- return (payload.offeredAgentIds ?? []).some((id) => String(id) === String(me));
266
- }
267
-
268
- #offer(payload) {
269
- const id = String(payload.callId);
270
- if (this._resolved.has(id)) return;
271
- const existing = this.calls.get(id);
272
- if (existing) {
273
- existing._update(payload); // e.g. the SDP arriving after a resync saw the call
274
- return;
275
- }
276
- const call = new Call(this, payload, 'ringing');
277
- this.calls.set(id, call);
278
- this.emit('incoming', call);
279
- }
280
-
281
- // ── Supervisor board ──────────────────────────────────────────────────────
282
-
283
- #boardUpsert(payload) {
284
- if (!this.isSupervisor || payload?.callId == null) return;
285
- const id = String(payload.callId);
286
- const { sdpOffer, sdpAnswer, ...fields } = payload;
287
- const known = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
288
- const view = { ...(this.board.get(id) ?? {}), ...known };
289
- this.board.set(id, view);
290
- this.emit('boardCall', view);
291
- }
292
-
293
- #boardEnd(payload) {
294
- const id = String(payload.callId);
295
- this.monitors.get(id)?._end('call_ended');
296
- const view = this.board.get(id);
297
- if (!view) return;
298
- this.board.delete(id);
299
- this.emit('boardCallEnded', view, { terminationReason: payload.terminationReason ?? payload.reason, terminatedBy: payload.terminatedBy });
300
- }
301
-
302
- // calls:list is the truth after any (re)connect.
303
- async #reconcile({ ongoing = [] } = {}) {
304
- if (this.isSupervisor) {
305
- this.board.clear();
306
- for (const entry of ongoing) {
307
- if (TERMINAL.has(entry.status)) continue;
308
- const { sdpOffer, ...view } = entry;
309
- this.board.set(String(entry.callId), view);
310
- }
311
- this.emit('board', this.board);
312
- }
313
- const seen = new Set();
314
- for (const entry of ongoing) {
315
- if (!this.#isMine(entry)) continue;
316
- const id = String(entry.callId);
317
- seen.add(id);
318
- const local = this.calls.get(id);
319
- if (local) { local._update(entry); continue; }
320
- if (this._resolved.has(id) || TERMINAL.has(entry.status)) continue;
321
-
322
- const onOtherDevice = entry.deviceId && entry.deviceId !== this.deviceId;
323
- if (entry.status === 'RINGING' && entry.direction === 'INBOUND' && !entry.deviceId && entry.sdpOffer) {
324
- this.#offer(entry);
325
- } else if (onOtherDevice) {
326
- const call = new Call(this, entry, 'elsewhere');
327
- this.calls.set(id, call);
328
- this.emit('elsewhere', call);
329
- } else if (entry.deviceId === this.deviceId) {
330
- // Ours, on this device, but its media is gone (page reload): reconnect.
331
- const call = new Call(this, entry, 'connecting');
332
- this.calls.set(id, call);
333
- call._reconnect('page_reload').catch((err) => call._end('media_failed', { error: err.message }));
334
- }
335
- }
336
- // Calls we held that Calloop no longer lists are over.
337
- for (const [id, call] of this.calls) {
338
- if (!seen.has(id) && call.state !== 'dialing') call._end('gone');
339
- }
340
- }
341
-
342
- #listen() {
343
- const s = this.socket;
344
- const forCall = (payload) => this.calls.get(String(payload?.callId)) ?? null;
345
- const forMonitor = (payload) => this.monitors.get(String(payload?.callId)) ?? null;
346
- const onBoard = (payload) => this.board.has(String(payload?.callId));
347
-
348
- s.on('session:ready', (session) => {
349
- this.session = session;
350
- this.agent = session.agent;
351
- this.iceServers = session.iceServers ?? [];
352
- this.#scheduleRefresh();
353
- if (this._refreshing) {
354
- // A credentials refresh on the same connection: nothing else changed.
355
- this._refreshing = false;
356
- this.emit('sessionRefreshed', session);
357
- return;
358
- }
359
- // A (re)connect: monitoring doesn't survive the old socket.
360
- for (const monitor of [...this.monitors.values()]) monitor._end('disconnected');
361
- this._resolveReady(this);
362
- this.emit('ready', session);
363
- // An auxiliary connection takes no part in calls: nothing to resync.
364
- if (this.purpose === 'session') this.sync();
365
- });
366
- s.on('connect_error', (err) => {
367
- if (!this.session) this._rejectReady(err);
368
- this.emit('error', err);
369
- });
370
- s.on('disconnect', (reason) => {
371
- this._refreshing = false;
372
- this.emit('disconnected', reason);
373
- // Calloop ended the connection (e.g. the token's signing key was
374
- // revoked): socket.io doesn't retry that by itself.
375
- if (reason === 'io server disconnect') {
376
- clearTimeout(this._refreshTimer);
377
- for (const monitor of [...this.monitors.values()]) monitor._end('disconnected');
378
- this.closedReason = reason;
379
- if (!this.session) this._rejectReady(new Error('Calloop closed the connection'));
380
- this.emit('closed', { reason });
381
- }
382
- });
383
-
384
- s.on('calls:list', (list) => this.#reconcile(list));
385
- s.on('call:incoming', (payload) => { if (this.#isMine(payload)) this.#offer(payload); });
386
-
387
- s.on('call:offer_withdrawn', (p) => {
388
- const call = forCall(p);
389
- if (call?.state === 'ringing') call._end('withdrawn', { withdrawnReason: p.reason });
390
- });
391
- s.on('call:handled', (p) => {
392
- // An accept answers the call: it's in progress (SIP sends no provider status for it).
393
- if (p.action === 'accepted' && onBoard(p)) this.#boardUpsert({ callId: p.callId, agentId: p.userId, agentName: p.agentName, status: 'IN_PROGRESS' });
394
- const call = forCall(p);
395
- if (!call || call.state !== 'ringing' || p.action !== 'accepted') return;
396
- const byMeHere = String(p.userId) === String(this.agent?.id) && p.deviceId === this.deviceId;
397
- if (!byMeHere) call._end('answered_elsewhere', { by: p.userId, agentName: p.agentName });
398
- });
399
- s.on('call:terminated', (p) => {
400
- forCall(p)?._end('terminated', { terminationReason: p.terminationReason ?? p.reason, terminatedBy: p.terminatedBy, failure: p.failure ?? null });
401
- // 'transferred' ends the previous agent's leg, not the call.
402
- if (p.reason !== 'transferred') this.#boardEnd(p);
403
- });
404
- s.on('call:status', (p) => {
405
- // The board speaks Calloop's statuses: a provider's ACCEPTED is IN_PROGRESS.
406
- if (onBoard(p)) this.#boardUpsert({ callId: p.callId, status: p.status === 'ACCEPTED' ? 'IN_PROGRESS' : p.status, ringingAt: p.ringingAt, answeredAt: p.answeredAt });
407
- const call = forCall(p);
408
- if (!call) return;
409
- call._update({ status: p.status, ringingAt: p.ringingAt, answeredAt: p.answeredAt });
410
- if (call.direction === 'OUTBOUND' && ['ACCEPTED', 'IN_PROGRESS'].includes(p.status)) call._customerAnsweredNow();
411
- });
412
-
413
- s.on('call:started', (p) => forCall(p)?._onStarted(p));
414
- s.on('call:reconnected', (p) => forCall(p)?._onReconnected(p));
415
- s.on('call:connection_superseded', (p) => forCall(p)?._superseded());
416
- s.on('connection:ice-candidate:server', (p) => {
417
- if (p.connectionType === 'MONITOR') { forMonitor(p)?._onServerCandidate(p.candidate); return; }
418
- if (p.connectionType && p.connectionType !== 'AGENT') return;
419
- forCall(p)?._onServerCandidate(p.candidate);
420
- });
421
-
422
- s.on('call:customer:media:state', (p) => forCall(p)?.emit('customerMedia', p));
423
- s.on('call:network:terminating', (p) => forCall(p)?.emit('networkTerminating', p));
424
- s.on('call:dtmf', (p) => forCall(p)?.emit('dtmf', p));
425
- s.on('call:supervisor:mode', (p) => {
426
- forCall(p)?.emit('supervisorMode', p);
427
- forMonitor(p)?._onMode(p.mode);
428
- });
429
- s.on('call:agent:private:changed', (p) => {
430
- forCall(p)?.emit('privateChanged', p);
431
- forMonitor(p)?.emit('agentPrivate', p);
432
- });
433
-
434
- // Supervisors: the board and monitoring.
435
- s.on('call:incoming:supervisor', (p) => this.#boardUpsert(p));
436
- s.on('call:initiated', (p) => this.#boardUpsert(p));
437
- s.on('call:transferred', (p) => this.#boardUpsert({
438
- callId: p.callId, agentId: p.agentId ?? p.userId, agentName: p.agentName, queueId: p.targetQueueId ?? p.queueId,
439
- }));
440
- s.on('call:ivr_state', (p) => this.#boardUpsert({ callId: p.callId, ivr: { nodeType: p.nodeType, nodeId: p.nodeId } }));
441
- for (const done of ['call:ivr_transferred', 'call:ivr_terminated', 'call:ivr_session_closed']) {
442
- s.on(done, (p) => { if (onBoard(p)) this.#boardUpsert({ callId: p.callId, ivr: null }); });
443
- }
444
- s.on('call:monitor:started', (p) => forMonitor(p)?._onStarted(p));
445
- s.on('call:monitor:offer', (p) => forMonitor(p)?._onOffer(p));
446
- s.on('call:monitor:mode:changed', (p) => forMonitor(p)?._onMode(p.mode));
447
- s.on('call:monitor:ended', (p) => forMonitor(p)?._end('ended'));
448
- s.on('call:monitor:agent:reconnected', (p) => forMonitor(p)?.emit('agentReconnected', p));
449
-
450
- s.on('call:agent_availability', (p) => {
451
- const entry = { agentId: p.userId, availability: p.availability, reason: p.reason, updatedAt: p.updatedAt };
452
- this.team.set(String(p.userId), entry);
453
- this.emit('team', entry);
454
- if (String(p.userId) !== String(this.agent?.id)) return;
455
- this.availability = p.availability;
456
- this.emit('availability', p);
457
- });
458
- s.on('call:agent_queue', (snapshot) => {
459
- this.queues.set(String(snapshot.queueId), snapshot);
460
- this.emit('queue', snapshot);
461
- });
462
- s.on('board:counters', (p) => this.emit('counters', p));
463
- s.on('call:error', (p) => {
464
- this.emit('error', p);
465
- if (p.code === 'MONITOR_FAILED' || p.code === 'MONITOR_MODE_NOT_ALLOWED') forMonitor(p)?._onError(p);
466
- if (p.callId != null && ENDING_ERRORS.has(p.code)) forCall(p)?._end('error', { code: p.code, message: p.message });
467
- });
468
- }
469
- }
470
-
471
- /** Connects and resolves once Calloop has identified the agent (session:ready). */
472
- export async function connect(opts) {
473
- const agent = new CalloopAgent(opts);
474
- try {
475
- await agent.ready;
476
- } catch (err) {
477
- // Without this the socket keeps retrying in the background and may
478
- // come up later as a session nobody uses (offered calls, presence).
479
- agent.close();
480
- throw err;
481
- }
482
- return agent;
483
- }
1
+ // An agent's connection to Calloop's agent gateway (docs/agent-protocol.md).
2
+ // Owns the socket, the agent's identity and ICE servers (session:ready),
3
+ // availability, queue snapshots and this agent's calls — and keeps them right
4
+ // across reconnects: every (re)connect resyncs from calls:list.
5
+ //
6
+ // Events:
7
+ // 'ready' (session) connected and identified; also after each reconnect
8
+ // 'sessionRefreshed' (session) new ICE/TURN credentials, fetched before they expire
9
+ // 'incoming' (call) a call is offered to this agent — ring
10
+ // 'elsewhere' (call) this agent's call is active on another device
11
+ // 'callState' (call, state, previous)
12
+ // 'callEnded' (call, { reason, ... })
13
+ // 'availability' ({ availability, reason? }) this agent
14
+ // 'team' ({ agentId, availability, reason? }) any agent of the tenant
15
+ // 'queue' (snapshot) one per queue, on change and on sync
16
+ // 'counters' (counters) supervisors: the tenant's live counts, pushed at most every 2 s
17
+ // 'error' ({ callId, code, message } | Error)
18
+ // 'disconnected' (reason)
19
+ // 'closed' ({ reason }) Calloop ended the connection for good (e.g. its signing key
20
+ // was revoked) and it won't reconnect: close() and connect again
21
+ // Supervisors only (agent.board = the tenant's live calls):
22
+ // 'board' (board) the whole board, after each (re)sync
23
+ // 'boardCall' (view) a call appeared or changed
24
+ // 'boardCallEnded' (view, { terminationReason, terminatedBy })
25
+ import { io as defaultIo } from 'socket.io-client';
26
+ import { Emitter } from './emitter.js';
27
+ import { Call } from './Call.js';
28
+ import { Monitor } from './Monitor.js';
29
+
30
+ const PROTOCOL = 1;
31
+ const TERMINAL = new Set(['TERMINATED', 'FAILED', 'CANCELLED']);
32
+ // Call errors after which the call can't go on here.
33
+ const ENDING_ERRORS = new Set(['ACCEPT_FAILED', 'CALL_ALREADY_ENDED', 'AGENT_MEDIA_NOT_READY', 'CALL_INITIATION_FAILED', 'PROVIDER_TRIGGER_FAILED']);
34
+ // ICE/TURN credentials are refreshed at this share of their lifetime, never sooner than MIN_REFRESH_MS.
35
+ const REFRESH_AT = 0.8;
36
+ const MIN_REFRESH_MS = 60_000;
37
+
38
+ function defaultDeviceId() {
39
+ const KEY = 'calloop.deviceId';
40
+ try {
41
+ const stored = globalThis.localStorage?.getItem(KEY);
42
+ if (stored) return stored;
43
+ const id = globalThis.crypto?.randomUUID?.() ?? `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`;
44
+ globalThis.localStorage?.setItem(KEY, id);
45
+ return id;
46
+ } catch {
47
+ return `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`;
48
+ }
49
+ }
50
+
51
+ // A URL that isn't TLS, to a host other than this machine.
52
+ function insecureUrl(url) {
53
+ try {
54
+ const u = new URL(url);
55
+ return !['https:', 'wss:'].includes(u.protocol) && !['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export class CalloopAgent extends Emitter {
62
+ /**
63
+ * @param {object} opts
64
+ * url Calloop base URL (https://api.calloop.net)
65
+ * token an agent JWT, or
66
+ * getToken async () => JWT — called on every (re)connect (tokens are short-lived)
67
+ * deviceId stable id for this device (default: one kept in localStorage)
68
+ * webrtc { RTCPeerConnection, MediaStream } (default: the browser's)
69
+ * getUserMedia async () => MediaStream for the microphone (default: navigator.mediaDevices)
70
+ * io socket.io-client's io (default: the bundled one)
71
+ * purpose 'session' (default): the agent's main connection. Anything else is
72
+ * an auxiliary connection (e.g. one that only declines a call): no
73
+ * presence, no redelivered calls, and no calls:sync on connect
74
+ */
75
+ constructor({ url, token, getToken, deviceId, webrtc, getUserMedia, io = defaultIo, purpose = 'session' } = {}) {
76
+ super();
77
+ if (!url) throw new Error('url is required');
78
+ if (!token && !getToken) throw new Error('token or getToken is required');
79
+ // The agent token and the call's signalling travel over this URL: plain http/ws
80
+ // exposes them to anyone on the network, so it's for a local server only.
81
+ if (insecureUrl(url)) console.warn(`[calloop] ${url} isn't https: the agent token and calls travel unencrypted. Use https:// outside local development.`);
82
+ this.url = url;
83
+ this.purpose = purpose;
84
+ this.deviceId = deviceId ?? defaultDeviceId();
85
+ this.webrtc = {
86
+ RTCPeerConnection: webrtc?.RTCPeerConnection ?? globalThis.RTCPeerConnection,
87
+ MediaStream: webrtc?.MediaStream ?? globalThis.MediaStream,
88
+ };
89
+ if (!this.webrtc.RTCPeerConnection) throw new Error('No RTCPeerConnection: pass webrtc.RTCPeerConnection');
90
+ this._getUserMedia = getUserMedia ?? (() => globalThis.navigator.mediaDevices.getUserMedia({
91
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
92
+ }));
93
+ this._getToken = getToken ?? (async () => token);
94
+
95
+ this.session = null;
96
+ this.agent = null;
97
+ this.iceServers = [];
98
+ this.availability = null;
99
+ this.closedReason = null; // set when Calloop ended the connection for good ('closed')
100
+ this.calls = new Map(); // callId → Call
101
+ this.queues = new Map(); // queueId → snapshot
102
+ this.team = new Map(); // agentId → { agentId, availability, reason?, updatedAt? }
103
+ this.board = new Map(); // supervisors: callId → the tenant's live calls
104
+ this.monitors = new Map(); // supervisors: callId → Monitor
105
+ this._resolved = new Set(); // calls already over — redeliveries are ignored
106
+ this._refreshTimer = null;
107
+ this._refreshing = false;
108
+
109
+ this.socket = io(url, {
110
+ transports: ['websocket'],
111
+ auth: (cb) => {
112
+ Promise.resolve(this._getToken())
113
+ .then((t) => cb({ token: t, device_id: this.deviceId, protocol: PROTOCOL, purpose: this.purpose }))
114
+ .catch(() => cb({ token: null, device_id: this.deviceId, protocol: PROTOCOL, purpose: this.purpose }));
115
+ },
116
+ });
117
+ this.ready = new Promise((resolve, reject) => {
118
+ this._resolveReady = resolve;
119
+ this._rejectReady = reject;
120
+ });
121
+ this.#listen();
122
+ }
123
+
124
+ // ── Agent actions ─────────────────────────────────────────────────────────
125
+
126
+ /** 'AVAILABLE' or 'OFFLINE' (ON_CALL is Calloop's). Supervisors may pass another agent's id. */
127
+ setAvailability(availability, { agentId } = {}) {
128
+ this._send('agent:availability:set', agentId != null ? { availability, agentId } : { availability });
129
+ }
130
+
131
+ /** Re-read this agent's calls (supervisors: the tenant's) and the queue snapshots. */
132
+ sync() {
133
+ this._send('calls:sync');
134
+ }
135
+
136
+ // ── The board (docs/agent-protocol.md → Board) ────────────────────────────
137
+
138
+ /**
139
+ * Narrow this connection's board to some lines, queues or agents (any of
140
+ * them): { channelIds?, queueIds?, agentIds? }; {} = the whole tenant.
141
+ * Resolves with the filter applied.
142
+ */
143
+ async subscribeBoard(filter = {}) {
144
+ return (await this._ask('board:subscribe', filter)).filter ?? null;
145
+ }
146
+
147
+ /** Stop receiving the board on this connection. */
148
+ async unsubscribeBoard() {
149
+ await this._ask('board:unsubscribe', {});
150
+ }
151
+
152
+ /** A page of the live calls: { channelIds?, queueIds?, agentIds?, cursor?, limit? } → { calls, nextCursor }. */
153
+ boardCalls(query = {}) {
154
+ return this._ask('board:calls', query);
155
+ }
156
+
157
+ /** Supervisors: the tenant's counters now (they are also pushed as 'counters'). */
158
+ boardCounters() {
159
+ return this._ask('board:counters', {});
160
+ }
161
+
162
+ /**
163
+ * Start an outbound call the consumer created (POST /v1/tenants/{t}/calls):
164
+ * connects this agent's leg; Calloop then dials the customer.
165
+ */
166
+ async startOutbound(callId, { stream } = {}) {
167
+ const call = new Call(this, { callId, direction: 'OUTBOUND' }, 'dialing');
168
+ this.calls.set(String(callId), call);
169
+ await call._start(stream);
170
+ return call;
171
+ }
172
+
173
+ call(callId) {
174
+ return this.calls.get(String(callId)) ?? null;
175
+ }
176
+
177
+ get isSupervisor() {
178
+ return this.agent?.role === 'SUPERVISOR';
179
+ }
180
+
181
+ /**
182
+ * Supervisors: listen to a call, then setMode('whisper' | 'barge').
183
+ * stream: the supervisor's microphone (default: getUserMedia).
184
+ */
185
+ async monitor(callId, { stream } = {}) {
186
+ if (!this.isSupervisor) throw new Error('Only supervisors can monitor calls');
187
+ const id = String(callId);
188
+ const existing = this.monitors.get(id);
189
+ if (existing && existing.state !== 'ended') return existing;
190
+ const monitor = new Monitor(this, callId);
191
+ this.monitors.set(id, monitor);
192
+ try {
193
+ await monitor._start(stream);
194
+ } catch (err) {
195
+ monitor._end('failed');
196
+ throw err;
197
+ }
198
+ return monitor;
199
+ }
200
+
201
+ /** Supervisors: move a call to an agent ({ agentId }) or into a queue ({ queueId }). */
202
+ transferCall(callId, target) {
203
+ if (!target?.agentId && !target?.queueId) throw new Error('transfer needs { agentId } or { queueId }');
204
+ this._send('call:transfer', { callId, ...target });
205
+ }
206
+
207
+ /** New ICE/TURN credentials now (also done automatically before they expire). */
208
+ refreshSession() {
209
+ this._refreshing = true;
210
+ this._send('session:refresh');
211
+ }
212
+
213
+ close() {
214
+ clearTimeout(this._refreshTimer);
215
+ for (const call of this.calls.values()) call._closePeer();
216
+ for (const monitor of [...this.monitors.values()]) monitor._end('closed');
217
+ this.socket.close();
218
+ }
219
+
220
+ // ── Internals ─────────────────────────────────────────────────────────────
221
+
222
+ _send(event, payload) {
223
+ this.socket.emit(event, payload);
224
+ }
225
+
226
+ // A request answered through the socket's acknowledgement.
227
+ _ask(event, payload, timeoutMs = 10000) {
228
+ return new Promise((resolve, reject) => {
229
+ const timer = setTimeout(() => reject(new Error(`${event} timed out`)), timeoutMs);
230
+ this.socket.emit(event, payload, (res) => {
231
+ clearTimeout(timer);
232
+ if (res?.error) reject(Object.assign(new Error(res.error.message), { code: res.error.code }));
233
+ else resolve(res);
234
+ });
235
+ });
236
+ }
237
+
238
+ async _getMicrophone() {
239
+ return this._getUserMedia();
240
+ }
241
+
242
+ _forget(call, details) {
243
+ this.calls.delete(String(call.id));
244
+ this._resolved.add(String(call.id));
245
+ this.emit('callEnded', call, details);
246
+ }
247
+
248
+ _forgetMonitor(monitor) {
249
+ if (this.monitors.get(String(monitor.callId)) === monitor) this.monitors.delete(String(monitor.callId));
250
+ }
251
+
252
+ #scheduleRefresh() {
253
+ clearTimeout(this._refreshTimer);
254
+ const expiresAt = Date.parse(this.session?.iceServersExpireAt ?? '');
255
+ if (!Number.isFinite(expiresAt)) return; // static credentials: nothing to refresh
256
+ const delay = Math.max(MIN_REFRESH_MS, (expiresAt - Date.now()) * REFRESH_AT);
257
+ this._refreshTimer = setTimeout(() => this.refreshSession(), Math.min(delay, 2 ** 31 - 1));
258
+ this._refreshTimer.unref?.();
259
+ }
260
+
261
+ #isMine(payload) {
262
+ const me = this.agent?.id;
263
+ if (me == null) return false;
264
+ if (payload.agentId != null) return String(payload.agentId) === String(me);
265
+ return (payload.offeredAgentIds ?? []).some((id) => String(id) === String(me));
266
+ }
267
+
268
+ #offer(payload) {
269
+ const id = String(payload.callId);
270
+ if (this._resolved.has(id)) return;
271
+ const existing = this.calls.get(id);
272
+ if (existing) {
273
+ existing._update(payload); // e.g. the SDP arriving after a resync saw the call
274
+ return;
275
+ }
276
+ const call = new Call(this, payload, 'ringing');
277
+ this.calls.set(id, call);
278
+ this.emit('incoming', call);
279
+ }
280
+
281
+ // ── Supervisor board ──────────────────────────────────────────────────────
282
+
283
+ #boardUpsert(payload) {
284
+ if (!this.isSupervisor || payload?.callId == null) return;
285
+ const id = String(payload.callId);
286
+ const { sdpOffer, sdpAnswer, ...fields } = payload;
287
+ const known = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
288
+ const view = { ...(this.board.get(id) ?? {}), ...known };
289
+ this.board.set(id, view);
290
+ this.emit('boardCall', view);
291
+ }
292
+
293
+ #boardEnd(payload) {
294
+ const id = String(payload.callId);
295
+ this.monitors.get(id)?._end('call_ended');
296
+ const view = this.board.get(id);
297
+ if (!view) return;
298
+ this.board.delete(id);
299
+ this.emit('boardCallEnded', view, { terminationReason: payload.terminationReason ?? payload.reason, terminatedBy: payload.terminatedBy });
300
+ }
301
+
302
+ // calls:list is the truth after any (re)connect.
303
+ async #reconcile({ ongoing = [] } = {}) {
304
+ if (this.isSupervisor) {
305
+ this.board.clear();
306
+ for (const entry of ongoing) {
307
+ if (TERMINAL.has(entry.status)) continue;
308
+ const { sdpOffer, ...view } = entry;
309
+ this.board.set(String(entry.callId), view);
310
+ }
311
+ this.emit('board', this.board);
312
+ }
313
+ const seen = new Set();
314
+ for (const entry of ongoing) {
315
+ if (!this.#isMine(entry)) continue;
316
+ const id = String(entry.callId);
317
+ seen.add(id);
318
+ const local = this.calls.get(id);
319
+ if (local) { local._update(entry); continue; }
320
+ if (this._resolved.has(id) || TERMINAL.has(entry.status)) continue;
321
+
322
+ const onOtherDevice = entry.deviceId && entry.deviceId !== this.deviceId;
323
+ if (entry.status === 'RINGING' && entry.direction === 'INBOUND' && !entry.deviceId && entry.sdpOffer) {
324
+ this.#offer(entry);
325
+ } else if (onOtherDevice) {
326
+ const call = new Call(this, entry, 'elsewhere');
327
+ this.calls.set(id, call);
328
+ this.emit('elsewhere', call);
329
+ } else if (entry.deviceId === this.deviceId) {
330
+ // Ours, on this device, but its media is gone (page reload): reconnect.
331
+ const call = new Call(this, entry, 'connecting');
332
+ this.calls.set(id, call);
333
+ call._reconnect('page_reload').catch((err) => call._end('media_failed', { error: err.message }));
334
+ }
335
+ }
336
+ // Calls we held that Calloop no longer lists are over.
337
+ for (const [id, call] of this.calls) {
338
+ if (!seen.has(id) && call.state !== 'dialing') call._end('gone');
339
+ }
340
+ }
341
+
342
+ #listen() {
343
+ const s = this.socket;
344
+ const forCall = (payload) => this.calls.get(String(payload?.callId)) ?? null;
345
+ const forMonitor = (payload) => this.monitors.get(String(payload?.callId)) ?? null;
346
+ const onBoard = (payload) => this.board.has(String(payload?.callId));
347
+
348
+ s.on('session:ready', (session) => {
349
+ this.session = session;
350
+ this.agent = session.agent;
351
+ this.iceServers = session.iceServers ?? [];
352
+ this.#scheduleRefresh();
353
+ if (this._refreshing) {
354
+ // A credentials refresh on the same connection: nothing else changed.
355
+ this._refreshing = false;
356
+ this.emit('sessionRefreshed', session);
357
+ return;
358
+ }
359
+ // A (re)connect: monitoring doesn't survive the old socket.
360
+ for (const monitor of [...this.monitors.values()]) monitor._end('disconnected');
361
+ this._resolveReady(this);
362
+ this.emit('ready', session);
363
+ // An auxiliary connection takes no part in calls: nothing to resync.
364
+ if (this.purpose === 'session') this.sync();
365
+ });
366
+ s.on('connect_error', (err) => {
367
+ if (!this.session) this._rejectReady(err);
368
+ this.emit('error', err);
369
+ });
370
+ s.on('disconnect', (reason) => {
371
+ this._refreshing = false;
372
+ this.emit('disconnected', reason);
373
+ // Calloop ended the connection (e.g. the token's signing key was
374
+ // revoked): socket.io doesn't retry that by itself.
375
+ if (reason === 'io server disconnect') {
376
+ clearTimeout(this._refreshTimer);
377
+ for (const monitor of [...this.monitors.values()]) monitor._end('disconnected');
378
+ this.closedReason = reason;
379
+ if (!this.session) this._rejectReady(new Error('Calloop closed the connection'));
380
+ this.emit('closed', { reason });
381
+ }
382
+ });
383
+
384
+ s.on('calls:list', (list) => this.#reconcile(list));
385
+ s.on('call:incoming', (payload) => { if (this.#isMine(payload)) this.#offer(payload); });
386
+
387
+ s.on('call:offer_withdrawn', (p) => {
388
+ const call = forCall(p);
389
+ if (call?.state === 'ringing') call._end('withdrawn', { withdrawnReason: p.reason });
390
+ });
391
+ s.on('call:handled', (p) => {
392
+ // An accept answers the call: it's in progress (SIP sends no provider status for it).
393
+ if (p.action === 'accepted' && onBoard(p)) this.#boardUpsert({ callId: p.callId, agentId: p.userId, agentName: p.agentName, status: 'IN_PROGRESS' });
394
+ const call = forCall(p);
395
+ if (!call || call.state !== 'ringing' || p.action !== 'accepted') return;
396
+ const byMeHere = String(p.userId) === String(this.agent?.id) && p.deviceId === this.deviceId;
397
+ if (!byMeHere) call._end('answered_elsewhere', { by: p.userId, agentName: p.agentName });
398
+ });
399
+ s.on('call:terminated', (p) => {
400
+ forCall(p)?._end('terminated', { terminationReason: p.terminationReason ?? p.reason, terminatedBy: p.terminatedBy, failure: p.failure ?? null });
401
+ // 'transferred' ends the previous agent's leg, not the call.
402
+ if (p.reason !== 'transferred') this.#boardEnd(p);
403
+ });
404
+ s.on('call:status', (p) => {
405
+ // The board speaks Calloop's statuses: a provider's ACCEPTED is IN_PROGRESS.
406
+ if (onBoard(p)) this.#boardUpsert({ callId: p.callId, status: p.status === 'ACCEPTED' ? 'IN_PROGRESS' : p.status, ringingAt: p.ringingAt, answeredAt: p.answeredAt });
407
+ const call = forCall(p);
408
+ if (!call) return;
409
+ call._update({ status: p.status, ringingAt: p.ringingAt, answeredAt: p.answeredAt });
410
+ if (call.direction === 'OUTBOUND' && ['ACCEPTED', 'IN_PROGRESS'].includes(p.status)) call._customerAnsweredNow();
411
+ });
412
+
413
+ s.on('call:started', (p) => forCall(p)?._onStarted(p));
414
+ s.on('call:reconnected', (p) => forCall(p)?._onReconnected(p));
415
+ s.on('call:connection_superseded', (p) => forCall(p)?._superseded());
416
+ s.on('connection:ice-candidate:server', (p) => {
417
+ if (p.connectionType === 'MONITOR') { forMonitor(p)?._onServerCandidate(p.candidate); return; }
418
+ if (p.connectionType && p.connectionType !== 'AGENT') return;
419
+ forCall(p)?._onServerCandidate(p.candidate);
420
+ });
421
+
422
+ s.on('call:customer:media:state', (p) => forCall(p)?.emit('customerMedia', p));
423
+ s.on('call:network:terminating', (p) => forCall(p)?.emit('networkTerminating', p));
424
+ s.on('call:dtmf', (p) => forCall(p)?.emit('dtmf', p));
425
+ s.on('call:supervisor:mode', (p) => {
426
+ forCall(p)?.emit('supervisorMode', p);
427
+ forMonitor(p)?._onMode(p.mode);
428
+ });
429
+ s.on('call:agent:private:changed', (p) => {
430
+ forCall(p)?.emit('privateChanged', p);
431
+ forMonitor(p)?.emit('agentPrivate', p);
432
+ });
433
+
434
+ // Supervisors: the board and monitoring.
435
+ s.on('call:incoming:supervisor', (p) => this.#boardUpsert(p));
436
+ s.on('call:initiated', (p) => this.#boardUpsert(p));
437
+ s.on('call:transferred', (p) => this.#boardUpsert({
438
+ callId: p.callId, agentId: p.agentId ?? p.userId, agentName: p.agentName, queueId: p.targetQueueId ?? p.queueId,
439
+ }));
440
+ s.on('call:ivr_state', (p) => this.#boardUpsert({ callId: p.callId, ivr: { nodeType: p.nodeType, nodeId: p.nodeId } }));
441
+ for (const done of ['call:ivr_transferred', 'call:ivr_terminated', 'call:ivr_session_closed']) {
442
+ s.on(done, (p) => { if (onBoard(p)) this.#boardUpsert({ callId: p.callId, ivr: null }); });
443
+ }
444
+ s.on('call:monitor:started', (p) => forMonitor(p)?._onStarted(p));
445
+ s.on('call:monitor:offer', (p) => forMonitor(p)?._onOffer(p));
446
+ s.on('call:monitor:mode:changed', (p) => forMonitor(p)?._onMode(p.mode));
447
+ s.on('call:monitor:ended', (p) => forMonitor(p)?._end('ended'));
448
+ s.on('call:monitor:agent:reconnected', (p) => forMonitor(p)?.emit('agentReconnected', p));
449
+
450
+ s.on('call:agent_availability', (p) => {
451
+ const entry = { agentId: p.userId, availability: p.availability, reason: p.reason, updatedAt: p.updatedAt };
452
+ this.team.set(String(p.userId), entry);
453
+ this.emit('team', entry);
454
+ if (String(p.userId) !== String(this.agent?.id)) return;
455
+ this.availability = p.availability;
456
+ this.emit('availability', p);
457
+ });
458
+ s.on('call:agent_queue', (snapshot) => {
459
+ this.queues.set(String(snapshot.queueId), snapshot);
460
+ this.emit('queue', snapshot);
461
+ });
462
+ s.on('board:counters', (p) => this.emit('counters', p));
463
+ s.on('call:error', (p) => {
464
+ this.emit('error', p);
465
+ if (p.code === 'MONITOR_FAILED' || p.code === 'MONITOR_MODE_NOT_ALLOWED') forMonitor(p)?._onError(p);
466
+ if (p.callId != null && ENDING_ERRORS.has(p.code)) forCall(p)?._end('error', { code: p.code, message: p.message });
467
+ });
468
+ }
469
+ }
470
+
471
+ /** Connects and resolves once Calloop has identified the agent (session:ready). */
472
+ export async function connect(opts) {
473
+ const agent = new CalloopAgent(opts);
474
+ try {
475
+ await agent.ready;
476
+ } catch (err) {
477
+ // Without this the socket keeps retrying in the background and may
478
+ // come up later as a session nobody uses (offered calls, presence).
479
+ agent.close();
480
+ throw err;
481
+ }
482
+ return agent;
483
+ }