@subiz/wsclient 4.0.35 → 4.0.36

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