@subiz/wsclient 4.0.35 → 4.0.37

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/webrtc.js CHANGED
@@ -1,19 +1,26 @@
1
- var flow = require('@subiz/flow')
2
- var {logError, xhrsend, randomString} = require('./common.js')
1
+ var {xhrsend, randomString, reportCallbackError} = require('./common.js')
3
2
  const servers = {
4
3
  iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
5
4
  }
6
5
 
7
- // let connId = 'webrtc' + randomString(20)
8
- // env = {RTCPeerConnection, RTCIceCandidate, RTCSessionDescription, setTimeout, setInterval, clearInterval, jsonify, parseJSON}
9
- // set env._is_webrtc_local to true for local testing
6
+ // env = {RTCPeerConnection, RTCIceCandidate, RTCSessionDescription, setTimeout, clearTimeout, setInterval, clearInterval, jsonify, parseJSON}
10
7
  function WebRTCConn(options) {
11
8
  let {apiUrl, access_token, accid, agid, realtime, callAPI, env, onTrack, onEvent, collect} = options
12
- if (!collect) collect = function () {}
13
- if (!apiUrl) {
14
- apiUrl = 'https://callcenter.subiz.com.vn/wc/'
15
- if (env._is_webrtc_local) apiUrl = 'http://localhost:8008/'
16
- }
9
+ // Application callbacks must never interrupt signaling or resource cleanup.
10
+ let protect =
11
+ (cb) =>
12
+ (...args) => {
13
+ try {
14
+ let result = cb && cb(...args)
15
+ if (result && result.then) env.Promise.resolve(result).catch(reportCallbackError)
16
+ } catch (err) {
17
+ reportCallbackError(err)
18
+ }
19
+ }
20
+ onEvent = protect(onEvent)
21
+ onTrack = protect(onTrack)
22
+ collect = protect(collect)
23
+ if (!apiUrl) apiUrl = 'https://callcenter.subiz.com.vn/wc/'
17
24
 
18
25
  let dead = false
19
26
  callAPI = callAPI || xhrsend // allow hook
@@ -27,124 +34,572 @@ function WebRTCConn(options) {
27
34
  return env.JSON.stringify(str)
28
35
  } catch (e) {}
29
36
  }
30
- let streams = {}
31
37
  let serverCalls = {}
32
38
 
33
39
  let call2Connection = {}
34
40
  let dialingRequest = {}
35
41
  let answerRequest = {}
36
42
  let endRequest = {}
43
+ let callSeenAt = {}
44
+ let publish
45
+
46
+ // ICE consent is the primary liveness signal. RTP is only a secondary signal because
47
+ // silence is a valid call state and must not cause an immediate local hangup.
48
+ const disconnectGraceMs = 30000
49
+ const rtpStaleMs = 90000
50
+ const inviteStaleMs = 120000
51
+ const setupTimeoutMs = options.setupTimeoutMs > 0 ? options.setupTimeoutMs : 60000
52
+ let operations = new Set()
53
+
54
+ let sessionsByCall = {}
55
+ let sessionsByConn = {}
56
+
57
+ let getSessionByCall = (callid) => {
58
+ if (!callid) return undefined
59
+ return sessionsByCall[callid]
60
+ }
61
+
62
+ let getSessionByConn = (connId) => {
63
+ if (!connId) return undefined
64
+ return sessionsByConn[connId]
65
+ }
66
+
67
+ let getConnIdByCall = (callid) => {
68
+ let session = getSessionByCall(callid)
69
+ return (session && session.connId) || call2Connection[callid]
70
+ }
71
+
72
+ // Server trả `api_url` trong response /listen|/call = node đang neo
73
+ // PeerConnection. Client pin session vào đó: các request per-call sau
74
+ // (finish/ice/answer/dtmf/refer/hangup) đi thẳng node neo, khỏi qua hop proxy
75
+ // gateway. Server không trả api_url (single node/dev) -> giữ nguyên apiUrl.
76
+ let apiHostSuffixes = options.apiHostSuffixes || ['.subiz.com.vn']
77
+ let normalizeApiBase = (u) => {
78
+ if (!u || typeof u != 'string') return undefined
79
+ if (u.slice(0, 8) != 'https://') return undefined
80
+ let host = u.slice(8).split('/')[0].split(':')[0].toLowerCase()
81
+ if (!host) return undefined
82
+ let ok = false
83
+ apiHostSuffixes.map((sfx) => {
84
+ if (!sfx) return
85
+ sfx = sfx.toLowerCase()
86
+ if (sfx[0] == '.') {
87
+ if (host.length > sfx.length && host.slice(-sfx.length) == sfx) ok = true
88
+ } else if (host == sfx) ok = true
89
+ })
90
+ if (!ok) return undefined
91
+ return u[u.length - 1] == '/' ? u : u + '/'
92
+ }
37
93
 
38
- let peers = {}
94
+ // chỉ nhận host thuộc whitelist — token nằm trong query string, nghe theo một
95
+ // api_url giả là dâng token cho host lạ
96
+ let pinSessionApiBase = (connId, body) => {
97
+ let data = typeof body == 'string' ? parseJSON(body) : body
98
+ let base = normalizeApiBase(data && data.api_url)
99
+ let session = getSessionByConn(connId)
100
+ if (!base || !session) return
101
+ if (session.apiBase != base) console.info('[webrtc]', 'PIN API HOST', 'call_id', session.callid, base)
102
+ session.apiBase = base
103
+ }
39
104
 
40
- // to receive incomming call
41
- realtime.subscribe([`webrtc.account.${accid}.agent.${agid}`])
105
+ let apiBaseFor = (callid) => {
106
+ let session = getSessionByCall(callid)
107
+ return (session && session.apiBase) || apiUrl
108
+ }
109
+
110
+ // POST per-call: ưu tiên host đã pin; lỗi MẠNG (status 0/-1, không phải 4xx/5xx)
111
+ // thì bỏ pin và thử lại một lần qua gateway mặc định — gateway vẫn proxy về
112
+ // home được nên không bao giờ tệ hơn trước khi pin
113
+ let postPerCall = (callid, path, body, cb) => {
114
+ if (dead) return cb && cb(jsonify({error: 'shutdown'}), -1)
115
+ let owner = getSessionByCall(callid)
116
+ let base = apiBaseFor(callid)
117
+ callAPI('post', base + path, body, (respBody, code) => {
118
+ if (dead || (owner && getSessionByCall(callid) !== owner)) return
119
+ if ((!code || code < 0) && base != apiUrl) {
120
+ console.warn('[webrtc]', 'PINNED HOST UNREACHABLE -> FALLBACK GATEWAY', 'call_id', callid, base)
121
+ let session = getSessionByCall(callid)
122
+ if (session && session.apiBase == base) session.apiBase = undefined
123
+ return callAPI('post', apiUrl + path, body, cb || function () {})
124
+ }
125
+ cb && cb(respBody, code)
126
+ })
127
+ }
128
+
129
+ let ensureSession = (callid) => {
130
+ if (!callid) return undefined
131
+ let session = sessionsByCall[callid]
132
+ if (!session) {
133
+ session = {
134
+ callid,
135
+ connId: undefined,
136
+ peer: undefined,
137
+ localStream: undefined,
138
+ remoteStream: undefined,
139
+ status: 'new',
140
+ }
141
+ sessionsByCall[callid] = session
142
+ }
143
+ return session
144
+ }
145
+
146
+ let bindSessionConnection = (callid, connId, peer) => {
147
+ let session = ensureSession(callid)
148
+ if (!session) return undefined
149
+ let connOwner = getSessionByConn(connId)
150
+ if (connOwner && connOwner !== session && connOwner.callid && connOwner.callid != callid) {
151
+ console.warn(
152
+ '[webrtc]',
153
+ 'SKIP bind connection owned by another call',
154
+ 'call_id',
155
+ callid,
156
+ 'conn_id',
157
+ connId,
158
+ 'owner_call_id',
159
+ connOwner.callid,
160
+ )
161
+ return undefined
162
+ }
163
+ if (session.connId && session.connId != connId && sessionsByConn[session.connId] === session) {
164
+ delete sessionsByConn[session.connId]
165
+ }
166
+ session.connId = connId
167
+ if (peer) session.peer = peer
168
+ if (connId) {
169
+ sessionsByConn[connId] = session
170
+ call2Connection[callid] = connId
171
+ }
172
+ return session
173
+ }
42
174
 
43
- let initSession = (cb) => {
175
+ let markSessionEnded = (callid) => {
176
+ let session = getSessionByCall(callid)
177
+ if (!session) return
178
+ session.status = 'ended'
179
+ session.cancelled = true
180
+ }
181
+
182
+ let applySessionMuted = (session) => {
183
+ if (!session || !session.localStream) return
184
+ session.localStream.getAudioTracks().forEach((track) => {
185
+ try {
186
+ track.enabled = !session.muted
187
+ } catch (e) {}
188
+ })
189
+ }
190
+
191
+ let isCallEndedLocally = (callid) => {
192
+ if (!callid) return false
193
+ let call = serverCalls[callid]
194
+ let session = getSessionByCall(callid)
195
+ return !!endRequest[callid] || (session && session.cancelled) || (call && call.status == 'ended')
196
+ }
197
+
198
+ let rememberCall = (callid) => {
199
+ if (callid && callSeenAt[callid] === undefined) callSeenAt[callid] = env.Date.now()
200
+ }
201
+
202
+ let expireCall = (call_info = {}) => {
203
+ let callid = call_info.call_id
204
+ if (!callid || isCallEndedLocally(callid)) return
205
+ let call = Object.assign({}, dialingRequest[callid], serverCalls[callid], call_info, {
206
+ call_id: callid,
207
+ status: 'ended',
208
+ ended: call_info.ended || env.Date.now(),
209
+ hangup_code: call_info.hangup_code || 'cancel',
210
+ })
211
+ serverCalls[callid] = call
212
+ publish({
213
+ created: env.Date.now(),
214
+ type: 'call_ended',
215
+ data: {call_info: call},
216
+ })
217
+ return call
218
+ }
219
+
220
+ let clearDisconnectTimer = (session) => {
221
+ if (!session || !session.disconnectTimer) return
222
+ if (env.clearTimeout) env.clearTimeout(session.disconnectTimer)
223
+ else env.clearInterval(session.disconnectTimer)
224
+ session.disconnectTimer = undefined
225
+ session.disconnectedAt = undefined
226
+ }
227
+
228
+ let expireTransportSession = (session, hangupCode) => {
229
+ if (!session || session.transportEnded || isCallEndedLocally(session.callid)) return
230
+ session.transportEnded = true
231
+ clearDisconnectTimer(session)
232
+ expireCall({
233
+ call_id: session.callid,
234
+ device_id: session.connId,
235
+ hangup_code: hangupCode,
236
+ })
237
+ }
238
+
239
+ let handlePeerStateChange = (callid, connId, peer) => {
240
+ let session = getSessionByConn(connId)
241
+ if (!session || session.callid != callid || session.peer !== peer || session.transportEnded) return
242
+ let state = peer.connectionState
243
+ let iceState = peer.iceConnectionState
244
+ let now = env.Date.now()
245
+ let connected = state == 'connected' || iceState == 'connected' || iceState == 'completed'
246
+ let failed = state == 'failed' || iceState == 'failed'
247
+ let closed = state == 'closed' || iceState == 'closed'
248
+ let disconnected = state == 'disconnected' || iceState == 'disconnected'
249
+
250
+ if (failed || closed) {
251
+ return expireTransportSession(session, failed ? 'webrtc_transport_failed' : 'webrtc_transport_closed')
252
+ }
253
+
254
+ if (disconnected) {
255
+ if (session.disconnectTimer) return
256
+ session.disconnectedAt = now
257
+ session.disconnectTimer = env.setTimeout(() => {
258
+ let current = getSessionByConn(connId)
259
+ if (!current || current.peer !== peer || current.transportEnded) return
260
+ let currentState = peer.connectionState
261
+ let currentIceState = peer.iceConnectionState
262
+ if (currentState != 'disconnected' && currentIceState != 'disconnected') {
263
+ clearDisconnectTimer(current)
264
+ return
265
+ }
266
+ expireTransportSession(current, 'webrtc_disconnected_timeout')
267
+ }, disconnectGraceMs)
268
+ return
269
+ }
270
+
271
+ if (connected) {
272
+ session.everConnected = true
273
+ session.lastTransportHealthyAt = now
274
+ }
275
+ clearDisconnectTimer(session)
276
+ }
277
+
278
+ let getLocalEndedCall = (callid) => {
279
+ return (
280
+ this.matchCall(callid) || {
281
+ call_id: callid,
282
+ ended: endRequest[callid] || env.Date.now(),
283
+ status: 'ended',
284
+ hangup_code: 'cancel',
285
+ }
286
+ )
287
+ }
288
+
289
+ // to receive incoming call
290
+ env.Promise.resolve()
291
+ .then(() => realtime.subscribe([`webrtc.account.${accid}.agent.${agid}`]))
292
+ .catch(reportCallbackError)
293
+
294
+ let initSession = (callid, op) => {
295
+ op.check()
44
296
  let connId = 'webrtc' + randomString(20)
45
297
  let peer = new env.RTCPeerConnection(servers)
46
- peers[connId] = peer
47
- peer.ontrack = (event) => onTrack && onTrack(event)
298
+ // call này đã có session gắn connection khác (listenCall rồi mới answerCall,
299
+ // hoặc retry join): phải đóng connection cũ trước khi bind cái mới — peer bị
300
+ // thay không tự chết, ICE/mic của nó tiếp tục chạy (leak)
301
+ let prev = getSessionByCall(callid)
302
+ let prevMuted = prev && prev.muted
303
+ if (prev && prev.peer && prev.connId && prev.connId != connId) {
304
+ cleanupConnection(prev.connId, 'replaced')
305
+ }
306
+ op.connId = connId
307
+ let session = bindSessionConnection(callid, connId, peer)
308
+ if (session) {
309
+ session.status = 'connecting'
310
+ if (prevMuted !== undefined) session.muted = prevMuted
311
+ }
312
+ peer.ontrack = (event) => {
313
+ let session = getSessionByConn(connId)
314
+ // session.peer !== peer: connection này đã bị thay bởi lần bind mới hơn —
315
+ // track của nó là track mồ côi, không được đưa lên UI
316
+ if (!session || session.peer !== peer || isCallEndedLocally(session.callid)) {
317
+ if (event.streams && event.streams[0]) stopStream(event.streams[0], connId)
318
+ return
319
+ }
320
+ if (event.streams && event.streams[0]) session.remoteStream = event.streams[0]
321
+ onTrack && onTrack(event)
322
+ }
323
+ let onPeerStateChange = () => handlePeerStateChange(callid, connId, peer)
324
+ peer.onconnectionstatechange = onPeerStateChange
325
+ peer.oniceconnectionstatechange = onPeerStateChange
48
326
  waitForRemoteDescription[connId] = new env.Promise((rs) => (readyToSendCandidateResolve[connId] = rs))
49
327
 
50
328
  peer.onicecandidate = (event) => {
51
329
  if (!event.candidate) return
52
- if (dead) return
53
- let url = `${apiUrl}ice-candidates?x-access-token=${access_token}&connection_id=${connId}&account_id=${accid}`
54
- callAPI('post', url, jsonify(event.candidate))
55
- }
56
-
57
- peer.onnegotiationneeded = (ev) => console.log('NNNEED RE-NEGOTIATION', ev)
58
- realtime
59
- .subscribe([
60
- `webrtc.account.${accid}.agent.${agid}`,
61
- `webrtc.account.${accid}.agent.${agid}.webrtc_connection.${connId}`,
62
- ])
63
- .then((err) => {
64
- // create peer connection
65
- cb(err, connId)
330
+ if (dead || getSessionByConn(connId) !== session || isCallEndedLocally(callid)) return
331
+ postPerCall(
332
+ callid,
333
+ `ice-candidates?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&account_id=${accid}`,
334
+ jsonify(event.candidate),
335
+ )
336
+ }
337
+
338
+ return session
339
+ }
340
+
341
+ let operationError = (code) => Object.assign(new Error(String(code)), {code})
342
+
343
+ // One operation spans microphone acquisition, subscription, SDP/ICE and the
344
+ // final signaling response. Its identity survives session replacement/cleanup.
345
+ let startOperation = (callid) => {
346
+ for (let previous of operations) {
347
+ if (previous.callid === callid) previous.cancel('replaced')
348
+ }
349
+ let op = {callid, waits: new Set(), aborters: new Set()}
350
+ op.check = () => {
351
+ if (dead || op.reason) throw operationError(op.reason || 'shutdown')
352
+ if (isCallEndedLocally(callid)) throw operationError('call_ended')
353
+ if (op.connId && getConnIdByCall(callid) !== op.connId) throw operationError('replaced')
354
+ }
355
+ op.clearDeadline = () => {
356
+ env.clearTimeout(op.timer)
357
+ op.timer = undefined
358
+ }
359
+ op.cancel = (reason) => {
360
+ if (op.reason) return
361
+ op.reason = reason
362
+ op.clearDeadline()
363
+ for (let abort of op.aborters) abort()
364
+ for (let reject of op.waits) reject(operationError(reason))
365
+ op.waits.clear()
366
+ }
367
+ op.wait = (promise) => {
368
+ return new env.Promise((resolve, reject) => {
369
+ op.waits.add(reject)
370
+ env.Promise.resolve(promise).then(
371
+ (value) => {
372
+ op.waits.delete(reject)
373
+ try {
374
+ op.check()
375
+ resolve(value)
376
+ } catch (err) {
377
+ reject(err)
378
+ }
379
+ },
380
+ (err) => {
381
+ op.waits.delete(reject)
382
+ reject(err)
383
+ },
384
+ )
385
+ try {
386
+ op.check()
387
+ } catch (err) {
388
+ op.waits.delete(reject)
389
+ reject(err)
390
+ }
66
391
  })
392
+ }
393
+ op.dispose = () => {
394
+ op.clearDeadline()
395
+ for (let abort of op.aborters) abort()
396
+ operations.delete(op)
397
+ }
398
+ op.timer = env.setTimeout(() => op.cancel('setup_timeout'), setupTimeoutMs)
399
+ operations.add(op)
400
+ return op
401
+ }
402
+
403
+ let acquireStream = (op, stream) =>
404
+ op.wait(
405
+ env.Promise.resolve(stream).then((stream) => {
406
+ // Permission prompts cannot be aborted. Dispose of their late streams.
407
+ if (dead || op.reason || isCallEndedLocally(op.callid)) {
408
+ stopStream(stream)
409
+ throw operationError(op.reason || (dead ? 'shutdown' : 'call_ended'))
410
+ }
411
+ op.stream = stream
412
+ if (!stream || !stream.getAudioTracks().some((track) => track.readyState !== 'ended'))
413
+ throw operationError('no_stream')
414
+ return stream
415
+ }),
416
+ )
417
+
418
+ let requestForOperation = (op, url, body) => {
419
+ op.check()
420
+ return op.wait(
421
+ new env.Promise((resolve, reject) => {
422
+ let settled = false
423
+ let transport
424
+ let abort = () => {
425
+ if (settled) return
426
+ settled = true
427
+ op.aborters.delete(abort)
428
+ try {
429
+ if (typeof transport === 'function') transport()
430
+ else if (transport && transport.abort) transport.abort()
431
+ } catch (err) {
432
+ reportCallbackError(err)
433
+ }
434
+ }
435
+ op.aborters.add(abort)
436
+ try {
437
+ transport = callAPI('post', url, body, (body, code) => {
438
+ if (settled) return
439
+ settled = true
440
+ op.aborters.delete(abort)
441
+ resolve({body, code})
442
+ })
443
+ // A custom transport may synchronously trigger shutdown before returning.
444
+ if (op.reason) {
445
+ if (typeof transport === 'function') transport()
446
+ else if (transport && transport.abort) transport.abort()
447
+ }
448
+ } catch (err) {
449
+ abort()
450
+ reject(err)
451
+ }
452
+ }),
453
+ )
454
+ }
455
+
456
+ let postForOperation = async (op, path, body) => {
457
+ let base = apiBaseFor(op.callid)
458
+ let out = await requestForOperation(op, base + path, body)
459
+ op.check()
460
+ if ((!out.code || out.code < 0) && base !== apiUrl) {
461
+ let session = getSessionByConn(op.connId)
462
+ if (session) session.apiBase = undefined
463
+ out = await requestForOperation(op, apiUrl + path, body)
464
+ }
465
+ return out
67
466
  }
68
467
 
69
- let checkIceConnected = (peer, cb) =>
70
- flow.loop(
71
- () =>
72
- new env.Promise((rs) => {
73
- let pass = peer && (peer.iceConnectionState == 'connected' || peer.iceConnectionState == 'completed')
74
- if (!pass) return env.setTimeout((_) => rs(true), 100) // keep looping util connected
75
- cb && cb()
76
- rs(false)
77
- }),
468
+ let connectOperation = async (op, stream, talk) => {
469
+ op.check()
470
+ op.stream = stream
471
+ let session = initSession(op.callid, op)
472
+ session.localStream = stream
473
+ applySessionMuted(session)
474
+ let subscribed = await op.wait(
475
+ realtime.subscribe([`webrtc.account.${accid}.agent.${agid}.webrtc_connection.${session.connId}`]),
78
476
  )
477
+ op.check()
478
+ if (subscribed && subscribed.error) throw operationError(subscribed.error)
479
+ session.status = 'joining'
480
+ let started = env.Date.now()
481
+ let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${session.connId}&call_id=${op.callid}&account_id=${accid}`
482
+ if (talk) url += '&talk=true'
483
+ let out = await requestForOperation(op, url)
484
+ op.check()
485
+ collect(talk ? 'join_call' : 'listen_call', op.callid, env.Date.now() - started)
486
+ if (out.code !== 200) throw operationError(out.body || 'listen_failed')
487
+ pinSessionApiBase(session.connId, out.body)
488
+ while (true) {
489
+ op.check()
490
+ let peer = session.peer
491
+ if (!peer || peer.signalingState === 'closed') throw operationError('closed')
492
+ if (peer.connectionState === 'failed' || peer.iceConnectionState === 'failed') throw operationError('failed')
493
+ if (peer.iceConnectionState === 'connected' || peer.iceConnectionState === 'completed') break
494
+ let timer
495
+ try {
496
+ await op.wait(
497
+ new env.Promise((resolve) => {
498
+ timer = env.setTimeout(resolve, 100)
499
+ }),
500
+ )
501
+ } finally {
502
+ env.clearTimeout(timer)
503
+ }
504
+ }
505
+ op.check()
506
+ session.status = 'connected'
507
+ return session
508
+ }
509
+
510
+ let failOperation = (op, err) => {
511
+ let reason = (err && (err.code || err.message)) || err || 'setup_failed'
512
+ let current = getSessionByCall(op.callid)
513
+ let replaced = reason === 'replaced' || (current && current.connId !== op.connId)
514
+ let base = apiBaseFor(op.callid)
515
+ if (op.connId) cleanupCallConnection(op.callid, op.connId)
516
+ stopStream(op.stream)
517
+ if (!dead && !replaced && reason !== 'call_ended' && !isCallEndedLocally(op.callid)) {
518
+ expireCall({call_id: op.callid, hangup_code: String(reason)})
519
+ if (reason === 'setup_timeout' && op.connId) signalHangup(op.callid, base)
520
+ }
521
+ return reason
522
+ }
79
523
 
80
524
  let readyToSendCandidateResolve = {}
81
525
  let waitForRemoteDescription = {}
82
- let stopStream = (stream) => {
526
+ let negotiationQueue = {} // connId -> promise chain, xử lý tuần tự các renegotiation offer
527
+ let stopStream = (stream, connId) => {
83
528
  if (!stream) return
84
529
  stream.getTracks().forEach((track) => {
85
- track.stop()
86
- track.enabled = false
530
+ try {
531
+ track.stop()
532
+ track.enabled = false
533
+ } catch (e) {}
87
534
  })
88
535
  }
89
536
 
90
- let syncCallWithServer = () => {
91
- if (dead) return env.clearInterval(_syncCallInterval)
92
- let url = `${apiUrl}calls?x-access-token=${access_token}&account_id=${accid}`
93
- callAPI('get', url, undefined, function (body, code) {
94
- if (code != 200) return
95
- body = parseJSON(body) || []
96
- let newCalls = {}
97
- if (!body.map) return
98
- body.map((call) => {
99
- newCalls[call.call_id] = call
100
- })
101
-
102
- // end all local calls that not found on the server
103
- Object.keys(serverCalls).map((i) => {
104
- let call = serverCalls[i]
105
- if (!call) return delete serverCalls[i]
106
- let callid = call.call_id
107
- if (newCalls[callid]) return
108
-
109
- // calls that are missing on the server
110
- newCalls[callid] = call
111
- if (!call.ended) call.ended = env.Date.now()
112
- if (!call.hangup_code) call.hangup_code = 'cancel'
113
- if (call.status != 'ended') {
114
- call.status = 'ended'
115
- publish({
116
- created: env.Date.now(),
117
- type: 'call_ended',
118
- data: {call_info: {hanup_code: 'outdated', call_id: callid}},
119
- })
120
- }
121
- })
537
+ let cleanupConnection = (connId, reason = 'call_ended') => {
538
+ if (!connId) return
539
+ let session = getSessionByConn(connId)
540
+ let peer = session && session.peer
541
+ if (session) {
542
+ session.transportEnded = true
543
+ clearDisconnectTimer(session)
544
+ }
545
+ if (peer) {
546
+ if (peer.getSenders) {
547
+ peer.getSenders().forEach((sender) => {
548
+ try {
549
+ if (sender.track) sender.track.stop()
550
+ } catch (e) {}
551
+ if (sender.replaceTrack) {
552
+ let promise = sender.replaceTrack(null)
553
+ if (promise && promise.catch) promise.catch(() => {})
554
+ }
555
+ })
556
+ }
557
+ if (peer.getReceivers) {
558
+ peer.getReceivers().forEach((receiver) => {
559
+ try {
560
+ if (receiver.track) receiver.track.stop()
561
+ } catch (e) {}
562
+ })
563
+ }
564
+ if (peer.getTransceivers) {
565
+ peer.getTransceivers().forEach((transceiver) => {
566
+ try {
567
+ if (transceiver.stop && peer.signalingState !== 'closed') transceiver.stop()
568
+ } catch (e) {}
569
+ })
570
+ }
571
+ try {
572
+ if (peer.signalingState !== 'closed') peer.close()
573
+ } catch (e) {}
574
+ }
122
575
 
123
- serverCalls = {}
124
- Object.values(newCalls).map((call) => {
125
- if (!call) return false
126
- if (call.status == 'ended') {
127
- // receive ended call
128
- serverCalls[call.call_id] = call
129
- return
130
- }
131
- // keep all dialing incomming calls
132
- if (call.status == 'dialing' && (call.direction == 'incoming' || call.direction == 'inbound')) {
133
- serverCalls[call.call_id] = call
134
- return
135
- }
576
+ let localStream = session && session.localStream
577
+ let remoteStream = session && session.remoteStream
578
+ stopStream(localStream, connId)
579
+ stopStream(remoteStream, connId)
580
+ if (session) {
581
+ session.peer = undefined
582
+ session.localStream = undefined
583
+ session.remoteStream = undefined
584
+ session.status = 'ended'
585
+ delete sessionsByConn[connId]
586
+ if (session.callid && sessionsByCall[session.callid] === session) delete sessionsByCall[session.callid]
587
+ }
588
+ delete waitForRemoteDescription[connId]
589
+ delete readyToSendCandidateResolve[connId]
590
+ delete negotiationQueue[connId]
591
+ for (let op of operations) {
592
+ if (op.connId === connId) op.cancel(reason)
593
+ }
594
+ }
136
595
 
137
- let connId = call.device_id
138
- let peer = peers[connId]
139
- if (!peer) return false // ignore active call that not in our peer
140
- call2Connection[call.call_id] = connId
141
- serverCalls[call.call_id] = call
142
- return true
143
- })
144
- })
596
+ let cleanupCallConnection = (callid, connId) => {
597
+ // An explicit connection ID is authoritative. Never replace it with a newer
598
+ // session's ID when a stale request or ICE waiter finishes.
599
+ let session = connId ? getSessionByConn(connId) : getSessionByCall(callid)
600
+ if (!session || session.callid !== callid) return
601
+ cleanupConnection(session.connId)
145
602
  }
146
- syncCallWithServer()
147
- let _syncCallInterval = env.setInterval(syncCallWithServer, 10000)
148
603
 
149
604
  let cleanEndedCall = () => {
150
605
  if (dead) return env.clearInterval(_endcalInterval)
@@ -153,46 +608,127 @@ function WebRTCConn(options) {
153
608
  })
154
609
 
155
610
  Object.keys(answerRequest).map((callid) => {
156
- if (env.Date.now() - endRequest[callid] > 60000) delete answerRequest[callid]
611
+ if (env.Date.now() - answerRequest[callid] > 60000) delete answerRequest[callid]
157
612
  })
158
613
 
159
614
  Object.keys(serverCalls).map((callid) => {
160
615
  let call = serverCalls[callid]
161
616
  if (!call) return delete serverCalls[callid]
162
617
  if (call.status != 'ended') return
163
- if (env.Date.now() - call.ended > 60000) delete serverCalls[callid]
618
+ // call.ended có thể thiếu -> NaN > 60000 luôn false, entry không bao giờ được dọn
619
+ if (env.Date.now() - (call.ended || 0) > 60000) delete serverCalls[callid]
620
+ })
621
+
622
+ Object.keys(dialingRequest).map((callid) => {
623
+ let call = this.matchCall(callid)
624
+ if (!call) return delete dialingRequest[callid]
625
+ if (call.status != 'ended') return
626
+ if (env.Date.now() - (call.ended || 0) > 60000) delete dialingRequest[callid]
164
627
  })
165
628
 
166
629
  Object.keys(call2Connection).map((callid) => {
167
630
  let call = this.matchCall(callid)
168
631
  if (!call) return delete call2Connection[callid]
169
632
  if (call.status != 'ended') return
170
- if (env.Date.now() - call.ended > 60000) delete call2Connection[callid]
633
+ if (env.Date.now() - (call.ended || 0) > 60000) delete call2Connection[callid]
634
+ })
635
+
636
+ Object.keys(callSeenAt).map((callid) => {
637
+ let call = serverCalls[callid] || dialingRequest[callid]
638
+ if (!call || (call.status == 'ended' && env.Date.now() - (call.ended || 0) > 60000)) {
639
+ delete callSeenAt[callid]
640
+ }
171
641
  })
172
642
  }
173
643
  let _endcalInterval = env.setInterval(cleanEndedCall, 120000)
174
644
 
175
- let endcallCb = {}
176
- let publish = (ev) => {
645
+ let refreshRtpStats = (session) => {
646
+ let peer = session && session.peer
647
+ if (!peer || !peer.getStats || session.transportEnded) return
648
+ let statsPromise
649
+ try {
650
+ statsPromise = peer.getStats()
651
+ } catch (e) {
652
+ return
653
+ }
654
+ if (!statsPromise || !statsPromise.then) return
655
+ statsPromise
656
+ .then((reports) => {
657
+ if (!reports || !reports.forEach || session.peer !== peer || session.transportEnded) return
658
+ let packets = 0
659
+ let hasRtpReport = false
660
+ reports.forEach((report) => {
661
+ if (report.type != 'inbound-rtp' && report.type != 'outbound-rtp') return
662
+ let kind = report.kind || report.mediaType
663
+ if (kind && kind != 'audio') return
664
+ hasRtpReport = true
665
+ packets += Number(report.packetsReceived || 0) + Number(report.packetsSent || 0)
666
+ })
667
+ if (!hasRtpReport) return
668
+ let now = env.Date.now()
669
+ // A renegotiation may replace the RTP stream and reset counters. Any counter
670
+ // change, including a decrease, is therefore evidence of fresh media activity.
671
+ if (session.rtpPackets === undefined || packets != session.rtpPackets) session.lastRtpAt = now
672
+ if (session.rtpObservedAt === undefined) session.rtpObservedAt = now
673
+ session.rtpPackets = packets
674
+
675
+ let call = serverCalls[session.callid] || dialingRequest[session.callid] || {}
676
+ let isInvite =
677
+ !call.status ||
678
+ call.status == 'new' ||
679
+ call.status == 'dialing' ||
680
+ call.status == 'ringing' ||
681
+ call.status == 'pending'
682
+ let lastRtpAt = session.lastRtpAt || session.rtpObservedAt
683
+ if (!isInvite && session.everConnected && lastRtpAt && now - lastRtpAt > rtpStaleMs) {
684
+ expireTransportSession(session, 'webrtc_rtp_timeout')
685
+ }
686
+ })
687
+ .catch(() => {})
688
+ }
689
+
690
+ let checkCallLiveness = () => {
691
+ if (dead) return env.clearInterval(_livenessInterval)
692
+ let now = env.Date.now()
693
+ Object.keys(sessionsByCall).map((callid) => {
694
+ let session = sessionsByCall[callid]
695
+ if (!session || !session.peer) return
696
+ handlePeerStateChange(callid, session.connId, session.peer)
697
+ refreshRtpStats(session)
698
+ })
699
+
700
+ let calls = Object.assign({}, dialingRequest, serverCalls)
701
+ Object.keys(calls).map((callid) => {
702
+ let call = calls[callid]
703
+ if (!call || call.status == 'ended' || getSessionByCall(callid)) return
704
+ rememberCall(callid)
705
+ let isInvite =
706
+ !call.status ||
707
+ call.status == 'new' ||
708
+ call.status == 'dialing' ||
709
+ call.status == 'ringing' ||
710
+ call.status == 'pending'
711
+ if (isInvite && now - callSeenAt[callid] > inviteStaleMs) {
712
+ expireCall({call_id: callid, hangup_code: 'invite_timeout'})
713
+ }
714
+ })
715
+ }
716
+ let _livenessInterval = env.setInterval(checkCallLiveness, 5000)
717
+
718
+ publish = (ev) => {
177
719
  if (ev.type == 'call_ended') {
178
720
  let callid = ev.data && ev.data.call_info && ev.data.call_info.call_id
179
- let connId = call2Connection[callid]
180
- let peer = peers[connId]
181
- if (peer) {
182
- delete peers[connId]
183
- peer.close()
184
- }
185
- stopStream(streams[connId])
721
+ let callInfo = ev.data && ev.data.call_info
722
+ let serverCall = serverCalls[callid] || {}
723
+ let connId = getConnIdByCall(callid) || (callInfo && callInfo.device_id) || serverCall.device_id
724
+ markSessionEnded(callid)
725
+ cleanupCallConnection(callid, connId)
186
726
  // do not clean up this now or some functions cannot work after the call ended
187
727
  // DONOT: delete call2Connection[callid]
188
- delete streams[connId]
189
728
 
190
- Object.keys(endcallCb).map((callid) => {
191
- let arr = endcallCb[callid]
192
- if (!arr || arr.length == 0 || !arr.map) return
193
- arr.map((cb) => cb())
194
- delete endcallCb[callid]
195
- })
729
+ for (let op of operations) {
730
+ if (op.callid === callid) op.cancel('call_ended')
731
+ }
196
732
  }
197
733
 
198
734
  let direction = ev.data && ev.data.call_info && ev.data.call_info.direction
@@ -202,7 +738,17 @@ function WebRTCConn(options) {
202
738
  }
203
739
 
204
740
  this.shutdown = () => {
741
+ if (dead) return
205
742
  dead = true
743
+ let callids = new Set(Object.keys(sessionsByCall))
744
+ env.clearInterval(_livenessInterval)
745
+ env.clearInterval(_endcalInterval)
746
+ for (let op of operations) {
747
+ callids.add(op.callid)
748
+ op.cancel('shutdown')
749
+ }
750
+ Object.keys(sessionsByConn).map((connId) => cleanupConnection(connId, 'shutdown'))
751
+ for (let callid of callids) expireCall({call_id: callid, hangup_code: 'shutdown'})
206
752
  }
207
753
 
208
754
  this.matchCall = (callid) => {
@@ -227,16 +773,19 @@ function WebRTCConn(options) {
227
773
  })
228
774
  })
229
775
 
776
+ // copy-on-write: calls là shallow copy nên value vẫn trỏ vào object trong
777
+ // serverCalls/dialingRequest — mutate trực tiếp là sửa lén state của store
230
778
  Object.keys(answerRequest).map((callid) => {
231
779
  let call = calls[callid]
232
780
  if (!call) return
233
- call.answer_requested = answerRequest[callid]
781
+ calls[callid] = Object.assign({}, call, {answer_requested: answerRequest[callid]})
234
782
  })
235
783
 
236
- Object.values(calls).map((call) => {
784
+ Object.keys(calls).map((callid) => {
785
+ let call = calls[callid]
237
786
  let direction = call && call.direction
238
- if (direction == 'incoming') call.direction = 'inbound'
239
- if (direction == 'outgoing') call.direction = 'outbound'
787
+ if (direction == 'incoming') calls[callid] = Object.assign({}, call, {direction: 'inbound'})
788
+ if (direction == 'outgoing') calls[callid] = Object.assign({}, call, {direction: 'outbound'})
240
789
  })
241
790
  return calls
242
791
  }
@@ -253,74 +802,175 @@ function WebRTCConn(options) {
253
802
  }
254
803
  if (answerRequest[callid]) call = Object.assign({}, call, {answer_requested: answerRequest[callid]})
255
804
  let direction = call && call.direction
256
- if (direction == 'incoming') call.direction = 'inbound'
257
- if (direction == 'outgoing') call.direction = 'outbound'
805
+ // copy-on-write: call có thể vẫn là object gốc trong serverCalls/dialingRequest
806
+ if (direction == 'incoming') call = Object.assign({}, call, {direction: 'inbound'})
807
+ if (direction == 'outgoing') call = Object.assign({}, call, {direction: 'outbound'})
258
808
 
259
809
  return call
260
810
  }
261
811
 
812
+ // debug only, dump internal state so we can see why a call disappears from the store
813
+ this.debugState = () => {
814
+ let calls = {}
815
+ Object.keys(serverCalls).map((callid) => {
816
+ let call = serverCalls[callid] || {}
817
+ calls[callid] = {
818
+ status: call.status,
819
+ direction: call.direction,
820
+ device_id: call.device_id,
821
+ started: call.started,
822
+ answered: call.answered,
823
+ ended: call.ended,
824
+ hangup_code: call.hangup_code,
825
+ }
826
+ })
827
+
828
+ let sessions = {}
829
+ Object.keys(sessionsByCall).map((callid) => {
830
+ let ss = sessionsByCall[callid] || {}
831
+ let peer = ss.peer
832
+ sessions[callid] = {
833
+ conn_id: ss.connId,
834
+ status: ss.status,
835
+ cancelled: !!ss.cancelled,
836
+ peer: !!peer,
837
+ connection: peer && peer.connectionState,
838
+ ice: peer && peer.iceConnectionState,
839
+ signaling: peer && peer.signalingState,
840
+ ever_connected: !!ss.everConnected,
841
+ disconnected_at: ss.disconnectedAt,
842
+ last_transport_healthy_at: ss.lastTransportHealthyAt,
843
+ last_rtp_at: ss.lastRtpAt,
844
+ rtp_packets: ss.rtpPackets,
845
+ local_stream: !!ss.localStream,
846
+ remote_stream: !!ss.remoteStream,
847
+ muted: !!ss.muted,
848
+ }
849
+ })
850
+
851
+ return {
852
+ api_url: apiUrl,
853
+ server_calls: calls,
854
+ dialing_requests: Object.keys(dialingRequest),
855
+ end_requests: Object.keys(endRequest),
856
+ answer_requests: Object.keys(answerRequest),
857
+ call2conn: Object.assign({}, call2Connection),
858
+ sessions,
859
+ }
860
+ }
861
+
862
+ this.expireCall = expireCall
863
+
262
864
  realtime.onEvent((ev) => {
263
865
  if (dead) return
264
866
  if (!ev || !ev.type || !ev.data) return
265
- if (env._is_webrtc_local && ev.user_id != '8') return // local
266
- if (!env._is_webrtc_local && ev.user_id != '9') return // prod
267
867
 
268
868
  if (ev.type == 'webrtc_candidate_added') {
269
869
  let ice_candidate = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.ice_candidate
270
870
  let connId = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.connection_id
271
871
  if (!connId) return
272
872
  if (!ice_candidate) return
273
- let peer = peers[connId]
873
+ let waitRemoteDescription = waitForRemoteDescription[connId]
874
+ let session = getSessionByConn(connId)
875
+ let peer = session && session.peer
274
876
 
275
- return waitForRemoteDescription[connId].then(() => peer && peer.addIceCandidate(parseJSON(ice_candidate)))
877
+ if (!peer || !waitRemoteDescription || isCallEndedLocally(session && session.callid)) {
878
+ console.info('[webrtc]', 'IGNORE CANDIDATE FOR CLOSED CONNECTION', connId)
879
+ return
880
+ }
881
+
882
+ return waitRemoteDescription
883
+ .then(() => {
884
+ let session = getSessionByConn(connId)
885
+ let peer = session && session.peer
886
+ if (!peer || isCallEndedLocally(session && session.callid)) return
887
+ let candidate = parseJSON(ice_candidate)
888
+ if (!candidate) return
889
+ return peer.addIceCandidate(candidate)
890
+ })
891
+ .catch((err) => {
892
+ // không catch thì thành unhandled rejection; candidate hỏng chỉ cần log
893
+ console.error('[webrtc]', 'ADD CANDIDATE FAILED', connId, err)
894
+ })
276
895
  }
277
896
 
278
897
  if (ev.type == 'webrtc_negotiation_offered') {
279
- // console.log('RT/RECEIVE OFFERED NEGOTIATION', ev)
280
898
  let jsonoffer = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.offer
281
899
  let connId = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.connection_id
282
- let peer = peers[connId]
283
- if (!peer) return
900
+ let session = getSessionByConn(connId)
901
+ let peer = session && session.peer
902
+ if (!peer || isCallEndedLocally(session && session.callid)) return
284
903
  let negid = ev.id
285
904
  // let negid = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.negotiation_id
286
905
  let offer = parseJSON(jsonoffer)
287
906
  if (!offer) return
907
+ // Xử lý TUẦN TỰ các offer trên cùng connection: 2 offer chồng nhau mà cùng
908
+ // setRemoteDescription là dính InvalidStateError (glare). Re-fetch session/
909
+ // peer bên trong vì đến lượt chạy thì connection có thể đã bị thay/đóng.
910
+ let handleOffer = () => {
911
+ let session = getSessionByConn(connId)
912
+ let peer = session && session.peer
913
+ if (!peer || isCallEndedLocally(session.callid)) return
914
+ return peer
915
+ .setRemoteDescription(offer)
916
+ .then(() => {
917
+ let resolveReadyToSendCandidate = readyToSendCandidateResolve[connId]
918
+ session = getSessionByConn(connId)
919
+ if (!session || isCallEndedLocally(session.callid) || !resolveReadyToSendCandidate) return
920
+ resolveReadyToSendCandidate()
921
+ peer = session.peer
922
+ let stream = session.localStream
923
+ if (!peer) return
924
+ stream &&
925
+ stream.getTracks().forEach((track) => {
926
+ let replaced = false
927
+ peer.getSenders().forEach((sender) => {
928
+ if (!sender.track) return
929
+ if (sender.track.kind != track.kind) return
930
+ sender.replaceTrack(track)
931
+ replaced = true
932
+ })
288
933
 
289
- peer
290
- .setRemoteDescription(offer)
291
- .then(() => {
292
- readyToSendCandidateResolve[connId]()
293
- let stream = streams[connId]
294
- stream &&
295
- stream.getTracks().forEach((track) => {
296
- let replaced = false
297
- peer.getSenders().forEach((sender) => {
298
- if (!sender.track) return
299
- if (sender.track.kind != track.kind) return
300
- sender.replaceTrack(track)
301
- replaced = true
934
+ if (!replaced) {
935
+ peer.addTrack(track, stream)
936
+ }
937
+ })
938
+ return peer.createAnswer()
939
+ })
940
+ .then((answer) => {
941
+ if (!answer) return
942
+ session = getSessionByConn(connId)
943
+ peer = session && session.peer
944
+ if (!peer || isCallEndedLocally(session && session.callid)) return
945
+ return peer.setLocalDescription(answer).then(() => {
946
+ if (
947
+ dead ||
948
+ getSessionByConn(connId) !== session ||
949
+ session.peer !== peer ||
950
+ isCallEndedLocally(session.callid)
951
+ )
952
+ return
953
+ // call_id: server dùng để log theo cuộc gọi
954
+ let path = `finish?x-access-token=${access_token}&connection_id=${connId}&call_id=${session.callid}&version=2&negotiation_id=${negid}&account_id=${accid}`
955
+ postPerCall(session.callid, path, jsonify(answer), function (body, code) {
956
+ if (code !== 200) {
957
+ return console.error('CANNOT FINISH', body)
958
+ }
959
+ return
302
960
  })
303
- if (!replaced) peer.addTrack(track, stream)
304
- })
305
- return peer.createAnswer()
306
- })
307
- .then((answer) =>
308
- peer.setLocalDescription(answer).then(() => {
309
- let url = `${apiUrl}finish?x-access-token=${access_token}&connection_id=${connId}&version=2&negotiation_id=${negid}&account_id=${accid}`
310
- callAPI('post', url, jsonify(answer), function (body, code) {
311
- if (code !== 200) {
312
- logError(accid, agid, body, 'finish:295')
313
- return console.error('CANNOT FINISH', body)
314
- }
315
- return
316
961
  })
317
- }),
318
- )
962
+ })
963
+ }
964
+ negotiationQueue[connId] = (negotiationQueue[connId] || env.Promise.resolve()).then(handleOffer).catch((err) => {
965
+ console.error('[webrtc]', 'NEGOTIATION FAILED', connId, err)
966
+ })
967
+ return
319
968
  }
320
969
 
321
970
  let call_info = ev.data.call_info || {}
322
971
  let callid = call_info.call_id
323
972
  if (!callid) return
973
+ rememberCall(callid)
324
974
  let type = ev.type
325
975
  if (type === 'call_invited') {
326
976
  if (agid == call_info.from_number) return // ignore our own invite
@@ -329,181 +979,214 @@ function WebRTCConn(options) {
329
979
  }
330
980
 
331
981
  if (type == 'call_ended') {
982
+ let wasEnded = isCallEndedLocally(callid)
332
983
  serverCalls[callid] = call_info
984
+ if (wasEnded) return // already ended and published locally, ignore duplicate server event
333
985
  return publish(ev)
334
986
  }
335
987
 
336
988
  if (ev.type === 'call_invite_expired') {
337
- if (Object.keys(peers).find((cid) => cid == call_info.device_id)) return // this call invitation is expired by us => ignore
989
+ if (getSessionByConn(call_info.device_id)) return // this call invitation is expired by us => ignore
338
990
  serverCalls[callid] = call_info
339
991
  publish(Object.assign({}, ev, {type: 'call_ended'})) // fake ended call
340
992
  return
341
993
  }
342
994
 
343
995
  let connId = call_info.device_id
344
- if (!Object.keys(peers).find((cid) => cid == connId)) return
996
+ let session = getSessionByConn(connId)
997
+ let peer = session && session.peer
998
+ if (!peer) return
345
999
  if (type == 'call_ringing' || type == 'call_joined') {
1000
+ session = bindSessionConnection(callid, connId, peer)
1001
+ if (!session) return
1002
+ session.status = call_info.status
346
1003
  serverCalls[callid] = call_info
347
1004
  return publish(ev)
348
1005
  }
349
1006
  })
350
1007
 
351
- this.makeCall = (number, fromnumber, streamPm, callid, campaignid, outboundcallentryid) => {
1008
+ this.makeCall = async (number, fromnumber, streamPm, callid, campaignid, outboundcallentryid, channel) => {
352
1009
  if (!callid) callid = 'webcall-' + randomString(30)
1010
+ if (dead || isCallEndedLocally(callid)) {
1011
+ if (streamPm && streamPm.then) env.Promise.resolve(streamPm).then(stopStream, reportCallbackError)
1012
+ return dead ? {error: 'shutdown'} : getLocalEndedCall(callid)
1013
+ }
1014
+ if (!streamPm || !streamPm.then) return {error: 'no_stream'}
1015
+ let op = startOperation(callid)
353
1016
  let now = env.Date.now()
1017
+ rememberCall(callid)
354
1018
  let call = {
355
1019
  account_id: accid,
356
1020
  call_id: callid,
357
1021
  direction: 'outbound',
1022
+ channel: channel || 'call',
358
1023
  member_id: agid,
359
1024
  started: now,
360
1025
  status: 'dialing',
361
1026
  to_number: number,
362
1027
  from_number: fromnumber,
1028
+ mic: 'waiting',
363
1029
  }
364
1030
  dialingRequest[callid] = call
365
- publish({created: now, type: 'call_ringing', data: {call_info: {call_id: callid}}}) // fake dialing
366
- return new env.Promise((rs) => {
367
- streamPm
368
- .then((stream) => {
369
- this.joinCall(callid, stream, (err, connId) => {
370
- if (err) {
371
- delete dialingRequest[callid]
372
- endRequest[callid] = env.Date.now()
373
- publish({created: env.Date.now(), type: 'call_ended', data: {call_info: {call_id: callid}}})
374
- return rs({error: err})
375
- }
1031
+ publish({created: now, type: 'call_ringing', data: {call_info: {call_id: callid}}})
1032
+ try {
1033
+ let stream = await acquireStream(op, streamPm)
1034
+ op.check()
1035
+ call.mic = 'ready'
1036
+ publish({created: now, type: 'call_ringing', data: {call_info: {call_id: callid}}})
1037
+ let session = await connectOperation(op, stream, true)
1038
+ op.check()
1039
+ let path = `call?x-access-token=${access_token}&connection_id=${session.connId}&call_id=${callid}&number=${encodeURIComponent(number)}&from_number=${encodeURIComponent(fromnumber || '')}&account_id=${accid}`
1040
+ if (channel) path += `&channel=${encodeURIComponent(channel)}`
1041
+ if (outboundcallentryid) path += `&outbound_call_entry_id=${encodeURIComponent(outboundcallentryid)}`
1042
+ if (campaignid) path += `&campaign_id=${encodeURIComponent(campaignid)}`
1043
+ let out = await postForOperation(op, path)
1044
+ op.check()
1045
+ if (out.code !== 200) throw operationError(out.body || 'call_failed')
1046
+ let body = parseJSON(out.body)
1047
+ pinSessionApiBase(session.connId, body)
1048
+ if (body && body.call_id) {
1049
+ serverCalls[callid] = body
1050
+ delete dialingRequest[callid]
1051
+ }
1052
+ op.clearDeadline() // The call can now last indefinitely, until an end event.
1053
+ if (body && body.status === 'ended') {
1054
+ publish({created: env.Date.now(), type: 'call_ended', data: {call_info: body}})
1055
+ return this.matchCall(callid)
1056
+ }
1057
+ await op.wait(new env.Promise(() => {})) // publish(call_ended) settles this wait
1058
+ } catch (err) {
1059
+ let reason = failOperation(op, err)
1060
+ return reason === 'call_ended' ? getLocalEndedCall(callid) : {error: reason}
1061
+ } finally {
1062
+ op.dispose()
1063
+ }
1064
+ }
376
1065
 
377
- let url = `${apiUrl}call?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number=${number}&from_number=${fromnumber}&account_id=${accid}`
378
- if (outboundcallentryid) url += `&outbound_call_entry_id=${outboundcallentryid}`
379
- if (campaignid) url += `&campaign_id=${campaignid}`
380
- callAPI('post', url, undefined, (body, code) => {
381
- if (code != 200) {
382
- logError(accid, agid, body, 'makeCall:365')
383
- delete dialingRequest[callid]
384
- endRequest[callid] = env.Date.now()
385
- publish({created: env.Date.now(), type: 'call_ended', data: {call_info: {call_id: callid}}})
386
- return rs({error: err})
387
- }
388
- body = parseJSON(body)
389
- if (body) serverCalls[callid] = body
390
- delete dialingRequest[callid]
391
-
392
- let call = this.matchCall(callid)
393
- if (call && call.status == 'ended') return rs(call)
394
- if (!endcallCb[callid]) endcallCb[callid] = []
395
- endcallCb[callid].push(() => rs(this.matchCall(callid)))
396
- })
397
- })
398
- })
399
- .catch((err) => rs({error: err}))
400
- })
1066
+ let signalHangup = (callid, base) => {
1067
+ let path = `hangup?x-access-token=${access_token}&call_id=${callid}&account_id=${accid}`
1068
+ try {
1069
+ callAPI('post', base + path, undefined, (body, code) => {
1070
+ if (!dead && (!code || code < 0) && base !== apiUrl) {
1071
+ try {
1072
+ callAPI('post', apiUrl + path, undefined, () => {})
1073
+ } catch (err) {
1074
+ reportCallbackError(err)
1075
+ }
1076
+ }
1077
+ })
1078
+ } catch (err) {
1079
+ reportCallbackError(err)
1080
+ }
401
1081
  }
402
1082
 
403
1083
  this.hangupCall = (callid) => {
404
- if (!callid) return
1084
+ if (dead || !callid || isCallEndedLocally(callid)) return
405
1085
  endRequest[callid] = env.Date.now()
406
-
407
- let call = serverCalls[callid]
408
- if (!call) {
409
- call = {call_id: callid}
410
- serverCalls[callid] = call
411
- }
1086
+ let call = serverCalls[callid] || {call_id: callid}
1087
+ serverCalls[callid] = call
412
1088
  call.status = 'ended'
413
1089
  call.hangup_code = 'cancel'
414
1090
  call.ended = env.Date.now()
415
- publish({created: env.Date.now(), type: 'call_ended', data: {call_info: {call_id: callid}}})
416
- callAPI('post', `${apiUrl}hangup?x-access-token=${access_token}&call_id=${callid}&account_id=${accid}`)
417
- }
418
-
419
- this.listenCall = (callid, cb) => {
420
- let start = env.Date.now()
421
- initSession((err, connId) => {
422
- let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&account_id=${accid}`
423
- callAPI('post', url, undefined, (body, code) => {
424
- if (code != 200) logError(accid, agid, body, 'listenCall:399')
425
- collect('listen_call', callid, env.Date.now() - start)
426
- return code != 200 ? cb(body) : cb()
427
- })
428
- })
1091
+ let hangupBase = apiBaseFor(callid)
1092
+ publish({created: env.Date.now(), type: 'call_ended', data: {call_info: Object.assign({}, call)}})
1093
+ signalHangup(callid, hangupBase)
429
1094
  }
430
1095
 
431
- this.joinCall = (callid, stream, cb) => {
432
- let start = env.Date.now()
433
- initSession((err, connId) => {
434
- call2Connection[callid] = connId
435
- streams[connId] = stream
436
- let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&talk=true&account_id=${accid}`
437
- callAPI('post', url, undefined, (body, code) => {
438
- if (code != 200) logError(accid, agid, body, 'joinCall:413')
439
- collect('join_call', callid, env.Date.now() - start)
440
- if (code != 200) return cb(body, connId)
441
- checkIceConnected(peers[connId], () => cb(undefined, connId))
442
- })
443
- })
1096
+ let join = async (callid, stream, cb, talk) => {
1097
+ if (dead || isCallEndedLocally(callid)) {
1098
+ stopStream(stream)
1099
+ protect(cb)(dead ? 'shutdown' : 'call_ended')
1100
+ return
1101
+ }
1102
+ let op = startOperation(callid)
1103
+ op.stream = stream
1104
+ try {
1105
+ let session = await connectOperation(op, stream, talk)
1106
+ op.check()
1107
+ op.dispose()
1108
+ protect(cb)(undefined, session.connId)
1109
+ } catch (err) {
1110
+ let reason = failOperation(op, err)
1111
+ protect(cb)(reason, op.connId)
1112
+ } finally {
1113
+ op.dispose()
1114
+ }
444
1115
  }
445
1116
 
446
- this.answerCall = (callid, stream) => {
447
- let call = serverCalls[callid]
448
- if (call && call.status == 'ended') return Promise.resolve({error: 'call_ended'})
449
- let now = env.Date.now()
450
- if (answerRequest[callid]) return Promise.resolve({body: call})
451
- answerRequest[callid] = now
452
- publish({created: env.Date.now(), type: 'call_ringing', data: {call_info: {call_id: callid}}}) // fake dialing
453
- return new env.Promise((rs) => {
454
- this.joinCall(callid, stream, (err, connId) => {
455
- if (err) {
456
- delete answerRequest[callid]
457
- endRequest[callid] = now
458
- publish({created: env.Date.now(), type: 'call_ended', data: {call_info: {call_id: callid}}})
459
- return rs({error: err})
460
- }
461
- let url = `${apiUrl}answer?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&account_id=${accid}`
462
- callAPI('post', url, undefined, (body, code) => {
463
- body = parseJSON(body)
464
- if (code != 200) {
465
- logError(accid, agid, body, 'answerCall:442')
466
- delete answerRequest[callid]
467
- endRequest[callid] = now
468
- publish({created: env.Date.now(), type: 'call_ended', data: {call_info: {call_id: callid}}})
469
- return rs({error: body})
470
- }
471
- return rs({body})
472
- })
473
- })
474
- })
1117
+ this.listenCall = (callid, cb) => join(callid, undefined, cb, false)
1118
+ this.joinCall = (callid, stream, cb) => join(callid, stream, cb, true)
1119
+
1120
+ this.answerCall = async (callid, stream) => {
1121
+ if (dead || isCallEndedLocally(callid)) {
1122
+ env.Promise.resolve(stream).then(stopStream, reportCallbackError)
1123
+ return {error: dead ? 'shutdown' : 'call_ended'}
1124
+ }
1125
+ if (answerRequest[callid]) return {body: this.matchCall(callid) || {call_id: callid}}
1126
+ let op = startOperation(callid)
1127
+ answerRequest[callid] = env.Date.now()
1128
+ publish({created: env.Date.now(), type: 'call_ringing', data: {call_info: {call_id: callid}}})
1129
+ try {
1130
+ stream = await acquireStream(op, stream)
1131
+ let session = await connectOperation(op, stream, true)
1132
+ op.check()
1133
+ let path = `answer?x-access-token=${access_token}&connection_id=${session.connId}&call_id=${callid}&account_id=${accid}`
1134
+ let out = await postForOperation(op, path)
1135
+ op.check()
1136
+ if (out.code !== 200) throw operationError(out.body || 'answer_failed')
1137
+ return {body: parseJSON(out.body)}
1138
+ } catch (err) {
1139
+ delete answerRequest[callid]
1140
+ let reason = failOperation(op, err)
1141
+ return reason === 'call_ended' ? {body: getLocalEndedCall(callid)} : {error: reason}
1142
+ } finally {
1143
+ op.dispose()
1144
+ }
475
1145
  }
476
1146
 
477
1147
  this.sendDtmf = (key, callid) =>
478
1148
  new env.Promise((rs) => {
479
1149
  if (!callid) return rs({body: {}})
480
- let connId = call2Connection[callid]
1150
+ let connId = getConnIdByCall(callid)
481
1151
  if (!connId) return rs({body: {}})
482
- let url = `${apiUrl}dtml?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&key={key}&account_id=${accid}`
483
- initSession(() =>
484
- callAPI('post', url, undefined, (body, code) => {
485
- if (code != 200) logError(accid, agid, body, 'sendDtmf:463')
486
- body = parseJSON(body)
487
- return code != 200 ? rs({error: body}) : rs({body})
488
- }),
489
- )
1152
+ let path = `dtmf?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&key=${encodeURIComponent(
1153
+ key,
1154
+ )}&account_id=${accid}`
1155
+ postPerCall(callid, path, undefined, (body, code) => {
1156
+ body = parseJSON(body)
1157
+ return code != 200 ? rs({error: body}) : rs({body})
1158
+ })
490
1159
  })
491
1160
 
492
1161
  this.transferCall = (tonumber, callid) =>
493
1162
  new env.Promise((rs) => {
494
1163
  if (!callid) return rs({})
495
- let connId = call2Connection[callid]
1164
+ let connId = getConnIdByCall(callid)
496
1165
  if (!connId) return rs({body: {}})
497
- let url = `${apiUrl}refer?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number={tonumber}&account_id=${accid}`
498
- initSession(() =>
499
- callAPI('post', url, undefined, (body, code) => {
500
- if (code != 200) logError(accid, agid, body, 'transferCall:478')
501
- collect('transfer_call', callid, env.Date.now() - start)
502
- body = parseJSON(body)
503
- return code != 200 ? rs({error: body}) : rs({body})
504
- }),
505
- )
1166
+ let start = env.Date.now()
1167
+ let path = `refer?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number=${encodeURIComponent(
1168
+ tonumber,
1169
+ )}&account_id=${accid}`
1170
+ postPerCall(callid, path, undefined, (body, code) => {
1171
+ collect('transfer_call', callid, env.Date.now() - start)
1172
+ body = parseJSON(body)
1173
+ return code != 200 ? rs({error: body}) : rs({body})
1174
+ })
506
1175
  })
1176
+
1177
+ this.setCallMuted = (callid, muted) => {
1178
+ if (dead) return {error: 'shutdown'}
1179
+ let session = ensureSession(callid)
1180
+ if (!session) return {error: 'call_not_found'}
1181
+ session.muted = !!muted
1182
+ applySessionMuted(session)
1183
+ return {body: {muted: session.muted}}
1184
+ }
1185
+
1186
+ this.isCallMuted = (callid) => {
1187
+ let session = getSessionByCall(callid)
1188
+ return !!(session && session.muted)
1189
+ }
507
1190
  }
508
1191
 
509
1192
  module.exports = WebRTCConn