@subiz/wsclient 4.0.34 → 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/.claude/settings.local.json +12 -0
- package/README.md +122 -10
- package/package.json +3 -3
- package/realtime.js +85 -54
- package/webphone.js +10 -9
- package/webrtc.js +803 -183
package/webrtc.js
CHANGED
|
@@ -4,18 +4,13 @@ const servers = {
|
|
|
4
4
|
iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
//
|
|
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
|
-
dead = false
|
|
13
|
+
let dead = false
|
|
19
14
|
callAPI = callAPI || xhrsend // allow hook
|
|
20
15
|
let parseJSON = (str) => {
|
|
21
16
|
try {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
358
|
+
try {
|
|
359
|
+
track.stop()
|
|
360
|
+
track.enabled = false
|
|
361
|
+
} catch (e) {}
|
|
87
362
|
})
|
|
88
363
|
}
|
|
89
364
|
|
|
90
|
-
let
|
|
91
|
-
if (
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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() -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
let
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
604
|
+
calls[callid] = Object.assign({}, call, {answer_requested: answerRequest[callid]})
|
|
234
605
|
})
|
|
235
606
|
|
|
236
|
-
Object.
|
|
607
|
+
Object.keys(calls).map((callid) => {
|
|
608
|
+
let call = calls[callid]
|
|
237
609
|
let direction = call && call.direction
|
|
238
|
-
if (direction == 'incoming') call
|
|
239
|
-
if (direction == 'outgoing') call
|
|
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
|
-
|
|
257
|
-
if (direction == '
|
|
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
|
|
696
|
+
let waitRemoteDescription = waitForRemoteDescription[connId]
|
|
697
|
+
let session = getSessionByConn(connId)
|
|
698
|
+
let peer = session && session.peer
|
|
274
699
|
|
|
275
|
-
|
|
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
|
|
283
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
901
|
+
return rs({error: body})
|
|
387
902
|
}
|
|
388
903
|
body = parseJSON(body)
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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 (
|
|
441
|
-
|
|
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
|
-
|
|
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
|
|
462
|
-
|
|
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 =
|
|
1086
|
+
let connId = getConnIdByCall(callid)
|
|
481
1087
|
if (!connId) return rs({body: {}})
|
|
482
|
-
let
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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 =
|
|
1101
|
+
let connId = getConnIdByCall(callid)
|
|
496
1102
|
if (!connId) return rs({body: {}})
|
|
497
|
-
let
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|