@calloop/agent-sdk 0.0.0-stage → 0.2.0

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.
@@ -0,0 +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
+ }
package/src/Monitor.js ADDED
@@ -0,0 +1,173 @@
1
+ // A supervisor listening to a call (docs/agent-protocol.md, Monitoring).
2
+ // Created by CalloopAgent.monitor() — don't construct directly.
3
+ //
4
+ // One audio line to Calloop: it sends the supervisor's microphone and
5
+ // receives the call — the customer and the agent, mixed by Calloop. A DIRECT
6
+ // call (no room to mix in) is listen-only: Calloop offers one line per side and
7
+ // the stream carries both. Calloop decides who hears the supervisor, so a mode
8
+ // change needs no renegotiation:
9
+ // listen the supervisor hears the call; nobody hears the supervisor
10
+ // whisper the agent hears the supervisor; the customer doesn't
11
+ // barge both hear the supervisor
12
+ //
13
+ // state: connecting → active → ended
14
+ // Events: 'state' (state, previous), 'stream' (MediaStream: the call),
15
+ // 'mode' (mode), 'modeRefused' ({ mode, code, message }), 'agentPrivate' ({ active }),
16
+ // 'agentReconnected', 'ended' ({ reason, code?, message? }).
17
+ import { Emitter } from './emitter.js';
18
+
19
+ const CONNECT_TIMEOUT_MS = 15000;
20
+ const MODES = new Set(['listen', 'whisper', 'barge']);
21
+
22
+ export class Monitor extends Emitter {
23
+ constructor(agent, callId) {
24
+ super();
25
+ this.agent = agent;
26
+ this.callId = callId;
27
+ this.state = 'connecting';
28
+ this.mode = 'listen';
29
+ this.muted = false;
30
+ this.stream = null;
31
+ this.localStream = null;
32
+ this._ownsLocalStream = false;
33
+ this.pc = null;
34
+ this._pendingRemote = [];
35
+ this._timer = null;
36
+ this._started = false; // Calloop confirmed the monitor (call:monitor:started)
37
+ this._requestedMode = null; // the last setMode() not yet confirmed
38
+ }
39
+
40
+ /** 'listen' | 'whisper' | 'barge'. */
41
+ setMode(mode) {
42
+ if (!MODES.has(mode)) throw new Error(`Unknown monitor mode "${mode}" (listen, whisper, barge)`);
43
+ if (this.state === 'ended') return;
44
+ this._requestedMode = mode;
45
+ this.agent._send('call:monitor:mode', { callId: this.callId, mode });
46
+ }
47
+
48
+ /** Mute the supervisor's microphone (whisper / barge). */
49
+ mute(muted = true) {
50
+ this.muted = Boolean(muted);
51
+ for (const track of this.localStream?.getAudioTracks() ?? []) track.enabled = !this.muted;
52
+ }
53
+
54
+ stop() {
55
+ if (this.state === 'ended') return;
56
+ this.agent._send('call:monitor:stop', { callId: this.callId });
57
+ this._end('stopped');
58
+ }
59
+
60
+ // ── Internals ─────────────────────────────────────────────────────────────
61
+
62
+ async _start(stream) {
63
+ const { RTCPeerConnection, MediaStream } = this.agent.webrtc;
64
+ const pc = new RTCPeerConnection({ iceServers: this.agent.iceServers });
65
+ this.pc = pc;
66
+ // A direct call is listened to by answering Calloop's offer, and never
67
+ // needs the microphone.
68
+ this._direct = this.agent.board?.get?.(String(this.callId))?.mediaTopology === 'DIRECT';
69
+
70
+ if (!this._direct) {
71
+ this._ownsLocalStream = !stream;
72
+ this.localStream = stream ?? await this.agent._getMicrophone();
73
+ const mic = this.localStream.getAudioTracks()[0];
74
+ pc.addTransceiver(mic ?? 'audio', { direction: 'sendrecv', streams: [this.localStream] });
75
+ }
76
+
77
+ pc.onicecandidate = (e) => {
78
+ if (!e.candidate || pc !== this.pc) return;
79
+ this.agent._send('connection:ice-candidate', {
80
+ callId: this.callId, connectionType: 'MONITOR',
81
+ candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex },
82
+ });
83
+ };
84
+ pc.ontrack = (e) => {
85
+ if (pc !== this.pc) return;
86
+ // Every line Calloop sends goes into the one stream (a direct call: both sides).
87
+ if (!this.stream) this.stream = new MediaStream([e.track]);
88
+ else this.stream.addTrack(e.track);
89
+ this.emit('stream', this.stream, e.track);
90
+ };
91
+ pc.onconnectionstatechange = () => {
92
+ if (pc !== this.pc) return;
93
+ if (pc.connectionState === 'connected') { clearTimeout(this._timer); this._setState('active'); }
94
+ else if (pc.connectionState === 'failed') this.stop();
95
+ };
96
+ this._timer = setTimeout(() => { if (this.state === 'connecting') this.stop(); }, CONNECT_TIMEOUT_MS);
97
+
98
+ if (this._direct) {
99
+ this.agent._send('call:monitor', { callId: this.callId });
100
+ return;
101
+ }
102
+ await pc.setLocalDescription(await pc.createOffer());
103
+ this.agent._send('call:monitor', { callId: this.callId, sdpOffer: pc.localDescription.sdp });
104
+ }
105
+
106
+ // Calloop's offer, for a monitor started without one.
107
+ async _onOffer({ sdpOffer }) {
108
+ if (!this.pc || this.pc.remoteDescription) return;
109
+ await this.pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
110
+ for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
111
+ await this.pc.setLocalDescription(await this.pc.createAnswer());
112
+ this.agent._send('call:monitor:answer', { callId: this.callId, sdpAnswer: this.pc.localDescription.sdp });
113
+ }
114
+
115
+ async _onStarted({ sdpAnswer }) {
116
+ this._started = true;
117
+ if (!sdpAnswer || !this.pc || this.pc.remoteDescription) return;
118
+ await this.pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
119
+ for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
120
+ }
121
+
122
+ async _onServerCandidate(candidate) {
123
+ if (!candidate) return;
124
+ if (this.pc?.remoteDescription) await this.pc.addIceCandidate(candidate).catch(() => { });
125
+ else this._pendingRemote.push(candidate);
126
+ }
127
+
128
+ // MONITOR_FAILED / MONITOR_MODE_NOT_ALLOWED for this call. Before Calloop
129
+ // confirmed the monitor it was refused and Calloop has already let it go
130
+ // (no leg, socket detached). Once started, only a mode change can fail:
131
+ // Calloop keeps the monitor in its current mode, and so do we.
132
+ _onError({ code, message }) {
133
+ if (this.state === 'ended') return;
134
+ if (this._started) {
135
+ const mode = this._requestedMode;
136
+ this._requestedMode = null;
137
+ this.emit('modeRefused', { mode, code, message });
138
+ return;
139
+ }
140
+ // A mode asked for while connecting may be what failed, with the
141
+ // monitor still up on Calloop's side: let it go there too.
142
+ if (this._requestedMode) this.agent._send('call:monitor:stop', { callId: this.callId });
143
+ this._end(code === 'MONITOR_MODE_NOT_ALLOWED' ? 'not_allowed' : 'failed', { code, message });
144
+ }
145
+
146
+ _onMode(mode) {
147
+ if (mode === this._requestedMode) this._requestedMode = null;
148
+ if (!MODES.has(mode) || mode === this.mode) return;
149
+ this.mode = mode;
150
+ this.emit('mode', mode);
151
+ }
152
+
153
+ _setState(next) {
154
+ if (this.state === next || this.state === 'ended') return;
155
+ const previous = this.state;
156
+ this.state = next;
157
+ this.emit('state', next, previous);
158
+ }
159
+
160
+ _end(reason, details = {}) {
161
+ if (this.state === 'ended') return;
162
+ clearTimeout(this._timer);
163
+ const pc = this.pc;
164
+ this.pc = null;
165
+ try { pc?.close(); } catch { /* already closed */ }
166
+ if (this._ownsLocalStream) for (const t of this.localStream?.getTracks() ?? []) t.stop();
167
+ const previous = this.state;
168
+ this.state = 'ended';
169
+ this.emit('state', 'ended', previous);
170
+ this.emit('ended', { reason, ...details });
171
+ this.agent._forgetMonitor(this, reason);
172
+ }
173
+ }
package/src/emitter.js ADDED
@@ -0,0 +1,25 @@
1
+ // A minimal event emitter (browser and Node, no dependencies).
2
+ export class Emitter {
3
+ #handlers = new Map();
4
+
5
+ on(event, fn) {
6
+ if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
7
+ this.#handlers.get(event).add(fn);
8
+ return () => this.off(event, fn);
9
+ }
10
+
11
+ once(event, fn) {
12
+ const off = this.on(event, (...args) => { off(); fn(...args); });
13
+ return off;
14
+ }
15
+
16
+ off(event, fn) {
17
+ this.#handlers.get(event)?.delete(fn);
18
+ }
19
+
20
+ emit(event, ...args) {
21
+ for (const fn of [...(this.#handlers.get(event) ?? [])]) {
22
+ try { fn(...args); } catch (err) { console.error(`[calloop] "${event}" handler threw:`, err); }
23
+ }
24
+ }
25
+ }