@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.
package/src/Call.js CHANGED
@@ -1,296 +1,296 @@
1
- // One call as this agent client sees it: its state, its WebRTC leg to Calloop
2
- // and the actions an agent takes on it. Created by CalloopAgent — don't
3
- // construct directly.
4
- //
5
- // state:
6
- // ringing offered to this agent (accept / decline)
7
- // dialing an outbound call this agent started; the customer isn't connected yet
8
- // connecting accepted / reconnecting; media is being set up
9
- // active media connected
10
- // elsewhere this agent's call, but its media is on another device (switchHere)
11
- // ended over; see call.endReason
12
- //
13
- // Events: 'state' (state, previous), 'remoteStream' (MediaStream), 'ended'
14
- // ({ reason, ...details }), 'updated' (call data), and in-call signals:
15
- // 'customerMedia' ({ state }), 'networkTerminating', 'dtmf' ({ digit }),
16
- // 'supervisorMode' ({ mode }), 'privateChanged' ({ active }).
17
- import { Emitter } from './emitter.js';
18
-
19
- // No media within this long after setting up the leg counts as a failure.
20
- const CONNECT_TIMEOUT_MS = 15000;
21
- // Media recovery: this many reconnect attempts, then the call is given up
22
- // (call:terminate reason 'system_failed').
23
- const MAX_RECONNECTS = 1;
24
-
25
- export class Call extends Emitter {
26
- constructor(agent, payload, state) {
27
- super();
28
- this.agent = agent;
29
- this.id = payload.callId;
30
- this.data = { ...payload };
31
- this.state = state;
32
- this.endReason = null;
33
- this.muted = false;
34
- this.pc = null;
35
- this.localStream = null;
36
- this.remoteStream = null;
37
- this._ownsLocalStream = false;
38
- this._bound = false; // the server knows this socket holds the leg
39
- this._pendingLocal = []; // our ICE candidates, until bound
40
- this._pendingRemote = []; // Calloop's, until the remote description is set
41
- this._reconnects = 0;
42
- this._connectTimer = null;
43
- }
44
-
45
- // ── What the call is ──────────────────────────────────────────────────────
46
- get direction() { return this.data.direction; }
47
- get customer() { return this.data.customer ?? {}; }
48
- get channel() { return this.data.channel; }
49
- get queueId() { return this.data.queueId ?? null; }
50
- get callUuid() { return this.data.callUuid ?? null; }
51
- get isRingAll() { return this.data.assignmentType === 'QUEUED' && this.data.agentId == null; }
52
- /** 'DIRECT': rtpengine alone carries the call (a personal line's 1:1 call). */
53
- get isDirect() { return this.data.mediaTopology === 'DIRECT'; }
54
-
55
- // ── Agent actions ─────────────────────────────────────────────────────────
56
-
57
- /** Answer an offered call. stream: the microphone (default: getUserMedia). */
58
- async accept({ stream } = {}) {
59
- if (this.state !== 'ringing') throw new Error(`Cannot accept a call that is ${this.state}`);
60
- if (!this.data.sdpOffer) throw new Error('The call has no offer yet');
61
- this._setState('connecting');
62
- try {
63
- // Candidates Calloop sent while the call rang belong to this leg: keep them.
64
- const pc = this._newPeer({ keepRemoteCandidates: true });
65
- await this._attachMicrophone(pc, stream);
66
- await pc.setRemoteDescription({ type: 'offer', sdp: this.data.sdpOffer });
67
- await this._flushRemote();
68
- await pc.setLocalDescription(await pc.createAnswer());
69
- this.agent._send('call:accept', { callId: this.id, sdpAnswer: pc.localDescription.sdp });
70
- this._markBound();
71
- } catch (err) {
72
- this._end('accept_failed', { error: err.message });
73
- throw err;
74
- }
75
- }
76
-
77
- /** Decline an offered call (in a queue, it passes to the next agent). */
78
- decline() {
79
- if (this.state !== 'ringing') return;
80
- this.agent._send('call:reject', { callId: this.id });
81
- this._end('declined');
82
- }
83
-
84
- /** Hang up. */
85
- hangup() {
86
- if (this.state === 'ended') return;
87
- if (this.state === 'dialing') this.agent._send('call:cancel', { callId: this.id });
88
- else this.agent._send('call:terminate', { callId: this.id });
89
- this._end('hangup');
90
- }
91
-
92
- /** Mute or unmute the microphone (supervisors see it). */
93
- mute(muted = true) {
94
- this.muted = Boolean(muted);
95
- for (const track of this.localStream?.getAudioTracks() ?? []) track.enabled = !this.muted;
96
- this.agent._send('call:agent:muted', { callId: this.id, muted: this.muted });
97
- }
98
-
99
- /** Transfer to an agent ({ agentId }) or into a queue ({ queueId }). */
100
- transfer(target) {
101
- if (!target?.agentId && !target?.queueId) throw new Error('transfer needs { agentId } or { queueId }');
102
- this.agent._send('call:transfer', { callId: this.id, ...target });
103
- }
104
-
105
- /** Talk privately to the monitoring supervisor (the customer can't hear). */
106
- setPrivate(active) {
107
- this.agent._send('call:agent:private', { callId: this.id, active: Boolean(active) });
108
- }
109
-
110
- /** Move a call that's active on another of your devices to this one. */
111
- async switchHere({ stream } = {}) {
112
- if (this.state !== 'elsewhere') throw new Error(`Cannot switch a call that is ${this.state}`);
113
- await this._reconnect('other_device', stream);
114
- }
115
-
116
- // ── Media ─────────────────────────────────────────────────────────────────
117
-
118
- // A new leg. Calloop's buffered candidates are for the previous leg and are
119
- // dropped — except on accept, where they arrived for this one while ringing.
120
- _newPeer({ keepRemoteCandidates = false } = {}) {
121
- this._closePeer();
122
- const RTCPeerConnection = this.agent.webrtc.RTCPeerConnection;
123
- const pc = new RTCPeerConnection({ iceServers: this.agent.iceServers });
124
- this.pc = pc;
125
- this._bound = false;
126
- this._pendingLocal = [];
127
- if (!keepRemoteCandidates) this._pendingRemote = [];
128
-
129
- pc.onicecandidate = (e) => {
130
- if (!e.candidate || pc !== this.pc) return;
131
- const candidate = { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex };
132
- if (this._bound) this._sendCandidate(candidate);
133
- else this._pendingLocal.push(candidate);
134
- };
135
- pc.ontrack = (e) => {
136
- if (pc !== this.pc) return;
137
- const MediaStream = this.agent.webrtc.MediaStream;
138
- this.remoteStream = e.streams?.[0] ?? (MediaStream ? new MediaStream([e.track]) : null);
139
- this.emit('remoteStream', this.remoteStream, e.track);
140
- };
141
- pc.onconnectionstatechange = () => {
142
- if (pc !== this.pc) return;
143
- if (pc.connectionState === 'connected') {
144
- clearTimeout(this._connectTimer);
145
- this._reconnects = 0;
146
- if (this.state === 'connecting' || (this.state === 'dialing' && this._customerAnswered)) this._setState('active');
147
- this._mediaConnected = true;
148
- } else if (pc.connectionState === 'failed') {
149
- this._mediaFailed('ice_failure');
150
- }
151
- };
152
- clearTimeout(this._connectTimer);
153
- this._connectTimer = setTimeout(() => {
154
- if (pc === this.pc && pc.connectionState !== 'connected') this._mediaFailed('ice_failure');
155
- }, CONNECT_TIMEOUT_MS);
156
- return pc;
157
- }
158
-
159
- async _attachMicrophone(pc, stream) {
160
- if (!this.localStream || this.localStream.getAudioTracks().every((t) => t.readyState === 'ended')) {
161
- this._ownsLocalStream = !stream;
162
- this.localStream = stream ?? await this.agent._getMicrophone();
163
- }
164
- for (const track of this.localStream.getAudioTracks()) {
165
- track.enabled = !this.muted;
166
- pc.addTrack(track, this.localStream);
167
- }
168
- }
169
-
170
- _markBound() {
171
- this._bound = true;
172
- for (const c of this._pendingLocal.splice(0)) this._sendCandidate(c);
173
- }
174
-
175
- _sendCandidate(candidate) {
176
- this.agent._send('connection:ice-candidate', { callId: this.id, candidate, connectionType: 'AGENT' });
177
- }
178
-
179
- async _onServerCandidate(candidate) {
180
- if (!candidate) return;
181
- if (this.pc?.remoteDescription) await this.pc.addIceCandidate(candidate).catch(() => { });
182
- else this._pendingRemote.push(candidate);
183
- }
184
-
185
- async _flushRemote() {
186
- for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
187
- }
188
-
189
- async _mediaFailed(trigger) {
190
- if (this.state === 'ended' || this.state === 'elsewhere' || this._recovering) return;
191
- if (this._reconnects >= MAX_RECONNECTS) {
192
- this.agent._send('call:terminate', { callId: this.id, reason: 'system_failed' });
193
- this._end('media_failed');
194
- return;
195
- }
196
- this._reconnects++;
197
- await this._reconnect(trigger).catch((err) => this._end('media_failed', { error: err.message }));
198
- }
199
-
200
- // A new leg for the same call (network change, page reload, another device).
201
- async _reconnect(trigger, stream) {
202
- this._recovering = true;
203
- try {
204
- this._setState('connecting');
205
- const pc = this._newPeer();
206
- await this._attachMicrophone(pc, stream);
207
- await pc.setLocalDescription(await pc.createOffer());
208
- this._awaitingReconnect = true;
209
- this.agent._send('call:reconnect', { callId: this.id, sdpOffer: pc.localDescription.sdp, reconnectTrigger: trigger });
210
- this._markBound();
211
- } finally {
212
- this._recovering = false;
213
- }
214
- }
215
-
216
- // Calloop's answer to our offer — or, had we reconnected without one, its
217
- // offer, answered with call:reconnect:answer.
218
- async _onReconnected({ sdpAnswer, sdpOffer }) {
219
- if (!this._awaitingReconnect || !this.pc) return;
220
- this._awaitingReconnect = false;
221
- if (sdpOffer) {
222
- await this.pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
223
- await this._flushRemote();
224
- await this.pc.setLocalDescription(await this.pc.createAnswer());
225
- this.agent._send('call:reconnect:answer', { callId: this.id, sdpAnswer: this.pc.localDescription.sdp });
226
- return;
227
- }
228
- await this.pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
229
- await this._flushRemote();
230
- }
231
-
232
- // Outbound: the agent connects its leg first; Calloop then dials the customer.
233
- async _start(stream) {
234
- const pc = this._newPeer();
235
- await this._attachMicrophone(pc, stream);
236
- await pc.setLocalDescription(await pc.createOffer());
237
- this.agent._send('call:start', { callId: this.id, sdpOffer: pc.localDescription.sdp });
238
- this._markBound();
239
- }
240
-
241
- async _onStarted(payload) {
242
- this._update(payload);
243
- if (payload.sdpAnswer && this.pc && !this.pc.remoteDescription) {
244
- await this.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdpAnswer });
245
- await this._flushRemote();
246
- }
247
- }
248
-
249
- // The customer answered an outbound call.
250
- _customerAnsweredNow() {
251
- this._customerAnswered = true;
252
- if (this.state === 'dialing' && this._mediaConnected) this._setState('active');
253
- }
254
-
255
- // Another device of ours took the call's media.
256
- _superseded() {
257
- this._closePeer();
258
- this._setState('elsewhere');
259
- }
260
-
261
- // ── Bookkeeping ───────────────────────────────────────────────────────────
262
-
263
- _update(payload) {
264
- Object.assign(this.data, Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined)));
265
- this.emit('updated', this.data);
266
- }
267
-
268
- _setState(next) {
269
- if (this.state === next || this.state === 'ended') return;
270
- const previous = this.state;
271
- this.state = next;
272
- this.emit('state', next, previous);
273
- this.agent.emit('callState', this, next, previous);
274
- }
275
-
276
- _closePeer() {
277
- clearTimeout(this._connectTimer);
278
- if (this.pc) {
279
- const pc = this.pc;
280
- this.pc = null;
281
- try { pc.close(); } catch { /* already closed */ }
282
- }
283
- }
284
-
285
- _end(reason, details = {}) {
286
- if (this.state === 'ended') return;
287
- this._closePeer();
288
- if (this._ownsLocalStream) for (const t of this.localStream?.getTracks() ?? []) t.stop();
289
- this.endReason = reason;
290
- const previous = this.state;
291
- this.state = 'ended';
292
- this.emit('state', 'ended', previous);
293
- this.emit('ended', { reason, ...details });
294
- this.agent._forget(this, { reason, ...details });
295
- }
296
- }
1
+ // One call as this agent client sees it: its state, its WebRTC leg to Calloop
2
+ // and the actions an agent takes on it. Created by CalloopAgent — don't
3
+ // construct directly.
4
+ //
5
+ // state:
6
+ // ringing offered to this agent (accept / decline)
7
+ // dialing an outbound call this agent started; the customer isn't connected yet
8
+ // connecting accepted / reconnecting; media is being set up
9
+ // active media connected
10
+ // elsewhere this agent's call, but its media is on another device (switchHere)
11
+ // ended over; see call.endReason
12
+ //
13
+ // Events: 'state' (state, previous), 'remoteStream' (MediaStream), 'ended'
14
+ // ({ reason, ...details }), 'updated' (call data), and in-call signals:
15
+ // 'customerMedia' ({ state }), 'networkTerminating', 'dtmf' ({ digit }),
16
+ // 'supervisorMode' ({ mode }), 'privateChanged' ({ active }).
17
+ import { Emitter } from './emitter.js';
18
+
19
+ // No media within this long after setting up the leg counts as a failure.
20
+ const CONNECT_TIMEOUT_MS = 15000;
21
+ // Media recovery: this many reconnect attempts, then the call is given up
22
+ // (call:terminate reason 'system_failed').
23
+ const MAX_RECONNECTS = 1;
24
+
25
+ export class Call extends Emitter {
26
+ constructor(agent, payload, state) {
27
+ super();
28
+ this.agent = agent;
29
+ this.id = payload.callId;
30
+ this.data = { ...payload };
31
+ this.state = state;
32
+ this.endReason = null;
33
+ this.muted = false;
34
+ this.pc = null;
35
+ this.localStream = null;
36
+ this.remoteStream = null;
37
+ this._ownsLocalStream = false;
38
+ this._bound = false; // the server knows this socket holds the leg
39
+ this._pendingLocal = []; // our ICE candidates, until bound
40
+ this._pendingRemote = []; // Calloop's, until the remote description is set
41
+ this._reconnects = 0;
42
+ this._connectTimer = null;
43
+ }
44
+
45
+ // ── What the call is ──────────────────────────────────────────────────────
46
+ get direction() { return this.data.direction; }
47
+ get customer() { return this.data.customer ?? {}; }
48
+ get channel() { return this.data.channel; }
49
+ get queueId() { return this.data.queueId ?? null; }
50
+ get callUuid() { return this.data.callUuid ?? null; }
51
+ get isRingAll() { return this.data.assignmentType === 'QUEUED' && this.data.agentId == null; }
52
+ /** 'DIRECT': rtpengine alone carries the call (a personal line's 1:1 call). */
53
+ get isDirect() { return this.data.mediaTopology === 'DIRECT'; }
54
+
55
+ // ── Agent actions ─────────────────────────────────────────────────────────
56
+
57
+ /** Answer an offered call. stream: the microphone (default: getUserMedia). */
58
+ async accept({ stream } = {}) {
59
+ if (this.state !== 'ringing') throw new Error(`Cannot accept a call that is ${this.state}`);
60
+ if (!this.data.sdpOffer) throw new Error('The call has no offer yet');
61
+ this._setState('connecting');
62
+ try {
63
+ // Candidates Calloop sent while the call rang belong to this leg: keep them.
64
+ const pc = this._newPeer({ keepRemoteCandidates: true });
65
+ await this._attachMicrophone(pc, stream);
66
+ await pc.setRemoteDescription({ type: 'offer', sdp: this.data.sdpOffer });
67
+ await this._flushRemote();
68
+ await pc.setLocalDescription(await pc.createAnswer());
69
+ this.agent._send('call:accept', { callId: this.id, sdpAnswer: pc.localDescription.sdp });
70
+ this._markBound();
71
+ } catch (err) {
72
+ this._end('accept_failed', { error: err.message });
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ /** Decline an offered call (in a queue, it passes to the next agent). */
78
+ decline() {
79
+ if (this.state !== 'ringing') return;
80
+ this.agent._send('call:reject', { callId: this.id });
81
+ this._end('declined');
82
+ }
83
+
84
+ /** Hang up. */
85
+ hangup() {
86
+ if (this.state === 'ended') return;
87
+ if (this.state === 'dialing') this.agent._send('call:cancel', { callId: this.id });
88
+ else this.agent._send('call:terminate', { callId: this.id });
89
+ this._end('hangup');
90
+ }
91
+
92
+ /** Mute or unmute the microphone (supervisors see it). */
93
+ mute(muted = true) {
94
+ this.muted = Boolean(muted);
95
+ for (const track of this.localStream?.getAudioTracks() ?? []) track.enabled = !this.muted;
96
+ this.agent._send('call:agent:muted', { callId: this.id, muted: this.muted });
97
+ }
98
+
99
+ /** Transfer to an agent ({ agentId }) or into a queue ({ queueId }). */
100
+ transfer(target) {
101
+ if (!target?.agentId && !target?.queueId) throw new Error('transfer needs { agentId } or { queueId }');
102
+ this.agent._send('call:transfer', { callId: this.id, ...target });
103
+ }
104
+
105
+ /** Talk privately to the monitoring supervisor (the customer can't hear). */
106
+ setPrivate(active) {
107
+ this.agent._send('call:agent:private', { callId: this.id, active: Boolean(active) });
108
+ }
109
+
110
+ /** Move a call that's active on another of your devices to this one. */
111
+ async switchHere({ stream } = {}) {
112
+ if (this.state !== 'elsewhere') throw new Error(`Cannot switch a call that is ${this.state}`);
113
+ await this._reconnect('other_device', stream);
114
+ }
115
+
116
+ // ── Media ─────────────────────────────────────────────────────────────────
117
+
118
+ // A new leg. Calloop's buffered candidates are for the previous leg and are
119
+ // dropped — except on accept, where they arrived for this one while ringing.
120
+ _newPeer({ keepRemoteCandidates = false } = {}) {
121
+ this._closePeer();
122
+ const RTCPeerConnection = this.agent.webrtc.RTCPeerConnection;
123
+ const pc = new RTCPeerConnection({ iceServers: this.agent.iceServers });
124
+ this.pc = pc;
125
+ this._bound = false;
126
+ this._pendingLocal = [];
127
+ if (!keepRemoteCandidates) this._pendingRemote = [];
128
+
129
+ pc.onicecandidate = (e) => {
130
+ if (!e.candidate || pc !== this.pc) return;
131
+ const candidate = { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex };
132
+ if (this._bound) this._sendCandidate(candidate);
133
+ else this._pendingLocal.push(candidate);
134
+ };
135
+ pc.ontrack = (e) => {
136
+ if (pc !== this.pc) return;
137
+ const MediaStream = this.agent.webrtc.MediaStream;
138
+ this.remoteStream = e.streams?.[0] ?? (MediaStream ? new MediaStream([e.track]) : null);
139
+ this.emit('remoteStream', this.remoteStream, e.track);
140
+ };
141
+ pc.onconnectionstatechange = () => {
142
+ if (pc !== this.pc) return;
143
+ if (pc.connectionState === 'connected') {
144
+ clearTimeout(this._connectTimer);
145
+ this._reconnects = 0;
146
+ if (this.state === 'connecting' || (this.state === 'dialing' && this._customerAnswered)) this._setState('active');
147
+ this._mediaConnected = true;
148
+ } else if (pc.connectionState === 'failed') {
149
+ this._mediaFailed('ice_failure');
150
+ }
151
+ };
152
+ clearTimeout(this._connectTimer);
153
+ this._connectTimer = setTimeout(() => {
154
+ if (pc === this.pc && pc.connectionState !== 'connected') this._mediaFailed('ice_failure');
155
+ }, CONNECT_TIMEOUT_MS);
156
+ return pc;
157
+ }
158
+
159
+ async _attachMicrophone(pc, stream) {
160
+ if (!this.localStream || this.localStream.getAudioTracks().every((t) => t.readyState === 'ended')) {
161
+ this._ownsLocalStream = !stream;
162
+ this.localStream = stream ?? await this.agent._getMicrophone();
163
+ }
164
+ for (const track of this.localStream.getAudioTracks()) {
165
+ track.enabled = !this.muted;
166
+ pc.addTrack(track, this.localStream);
167
+ }
168
+ }
169
+
170
+ _markBound() {
171
+ this._bound = true;
172
+ for (const c of this._pendingLocal.splice(0)) this._sendCandidate(c);
173
+ }
174
+
175
+ _sendCandidate(candidate) {
176
+ this.agent._send('connection:ice-candidate', { callId: this.id, candidate, connectionType: 'AGENT' });
177
+ }
178
+
179
+ async _onServerCandidate(candidate) {
180
+ if (!candidate) return;
181
+ if (this.pc?.remoteDescription) await this.pc.addIceCandidate(candidate).catch(() => { });
182
+ else this._pendingRemote.push(candidate);
183
+ }
184
+
185
+ async _flushRemote() {
186
+ for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
187
+ }
188
+
189
+ async _mediaFailed(trigger) {
190
+ if (this.state === 'ended' || this.state === 'elsewhere' || this._recovering) return;
191
+ if (this._reconnects >= MAX_RECONNECTS) {
192
+ this.agent._send('call:terminate', { callId: this.id, reason: 'system_failed' });
193
+ this._end('media_failed');
194
+ return;
195
+ }
196
+ this._reconnects++;
197
+ await this._reconnect(trigger).catch((err) => this._end('media_failed', { error: err.message }));
198
+ }
199
+
200
+ // A new leg for the same call (network change, page reload, another device).
201
+ async _reconnect(trigger, stream) {
202
+ this._recovering = true;
203
+ try {
204
+ this._setState('connecting');
205
+ const pc = this._newPeer();
206
+ await this._attachMicrophone(pc, stream);
207
+ await pc.setLocalDescription(await pc.createOffer());
208
+ this._awaitingReconnect = true;
209
+ this.agent._send('call:reconnect', { callId: this.id, sdpOffer: pc.localDescription.sdp, reconnectTrigger: trigger });
210
+ this._markBound();
211
+ } finally {
212
+ this._recovering = false;
213
+ }
214
+ }
215
+
216
+ // Calloop's answer to our offer — or, had we reconnected without one, its
217
+ // offer, answered with call:reconnect:answer.
218
+ async _onReconnected({ sdpAnswer, sdpOffer }) {
219
+ if (!this._awaitingReconnect || !this.pc) return;
220
+ this._awaitingReconnect = false;
221
+ if (sdpOffer) {
222
+ await this.pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
223
+ await this._flushRemote();
224
+ await this.pc.setLocalDescription(await this.pc.createAnswer());
225
+ this.agent._send('call:reconnect:answer', { callId: this.id, sdpAnswer: this.pc.localDescription.sdp });
226
+ return;
227
+ }
228
+ await this.pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
229
+ await this._flushRemote();
230
+ }
231
+
232
+ // Outbound: the agent connects its leg first; Calloop then dials the customer.
233
+ async _start(stream) {
234
+ const pc = this._newPeer();
235
+ await this._attachMicrophone(pc, stream);
236
+ await pc.setLocalDescription(await pc.createOffer());
237
+ this.agent._send('call:start', { callId: this.id, sdpOffer: pc.localDescription.sdp });
238
+ this._markBound();
239
+ }
240
+
241
+ async _onStarted(payload) {
242
+ this._update(payload);
243
+ if (payload.sdpAnswer && this.pc && !this.pc.remoteDescription) {
244
+ await this.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdpAnswer });
245
+ await this._flushRemote();
246
+ }
247
+ }
248
+
249
+ // The customer answered an outbound call.
250
+ _customerAnsweredNow() {
251
+ this._customerAnswered = true;
252
+ if (this.state === 'dialing' && this._mediaConnected) this._setState('active');
253
+ }
254
+
255
+ // Another device of ours took the call's media.
256
+ _superseded() {
257
+ this._closePeer();
258
+ this._setState('elsewhere');
259
+ }
260
+
261
+ // ── Bookkeeping ───────────────────────────────────────────────────────────
262
+
263
+ _update(payload) {
264
+ Object.assign(this.data, Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined)));
265
+ this.emit('updated', this.data);
266
+ }
267
+
268
+ _setState(next) {
269
+ if (this.state === next || this.state === 'ended') return;
270
+ const previous = this.state;
271
+ this.state = next;
272
+ this.emit('state', next, previous);
273
+ this.agent.emit('callState', this, next, previous);
274
+ }
275
+
276
+ _closePeer() {
277
+ clearTimeout(this._connectTimer);
278
+ if (this.pc) {
279
+ const pc = this.pc;
280
+ this.pc = null;
281
+ try { pc.close(); } catch { /* already closed */ }
282
+ }
283
+ }
284
+
285
+ _end(reason, details = {}) {
286
+ if (this.state === 'ended') return;
287
+ this._closePeer();
288
+ if (this._ownsLocalStream) for (const t of this.localStream?.getTracks() ?? []) t.stop();
289
+ this.endReason = reason;
290
+ const previous = this.state;
291
+ this.state = 'ended';
292
+ this.emit('state', 'ended', previous);
293
+ this.emit('ended', { reason, ...details });
294
+ this.agent._forget(this, { reason, ...details });
295
+ }
296
+ }