@subiz/wsclient 4.0.12 → 4.0.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/webrtc.js +216 -154
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subiz/wsclient",
3
- "version": "4.0.12",
3
+ "version": "4.0.14",
4
4
  "main": "realtime.js",
5
5
  "scripts": {
6
6
  "test": "node realtime.test.js"
package/webrtc.js CHANGED
@@ -8,15 +8,13 @@ const servers = {
8
8
  // env = {RTCPeerConnection, RTCIceCandidate, RTCSessionDescription, setTimeout, setInterval, jsonify, parseJSON}
9
9
  // set env._is_webrtc_local to true for local testing
10
10
  function WebRTCConn(options) {
11
- let {apiUrl, access_token, accid, agid, connId, realtime, callAPI, env, onTrack, onEvent} = options
11
+ let {apiUrl, access_token, accid, agid, realtime, callAPI, env, onTrack, onEvent} = options
12
12
  if (!apiUrl) {
13
13
  apiUrl = 'https://callcenter.subiz.com.vn/wc/'
14
14
  if (env._is_webrtc_local) apiUrl = 'http://localhost:8008/'
15
15
  }
16
16
 
17
17
  callAPI = callAPI || xhrsend // allow hook
18
- let callStream = {} // callid => stream
19
-
20
18
  let parseJSON = (str) => {
21
19
  try {
22
20
  return env.JSON.parse(str)
@@ -27,69 +25,58 @@ function WebRTCConn(options) {
27
25
  return env.JSON.stringify(str)
28
26
  } catch (e) {}
29
27
  }
30
- let currentStream
31
- let pendingStream
32
- let activeCalls = {}
28
+ let streams = {}
29
+ let serverCalls = {}
33
30
 
31
+ let call2Connection = {}
34
32
  let dialingRequest = {}
35
33
  let answerRequest = {}
36
34
  let endRequest = {}
37
35
 
38
- let subscribe = realtime.subscribe([
39
- `webrtc.account.${accid}.agent.${agid}`,
40
- `webrtc.account.${accid}.agent.${agid}.webrtc_connection.${connId}`,
41
- ])
42
-
43
- let peer
44
- // makeSure only returns when stream is added and negotiation is finished
45
- // its safe to call this function multiple times at once
46
- let makeSure = (cb) => {
47
- initConnection()
48
- subscribe.then(cb)
49
- }
36
+ let peers = {}
50
37
 
51
- let checkIceConnected = (cb) =>
52
- flow.loop(
53
- () =>
54
- new env.Promise((rs) => {
55
- let pass = peer && (peer.iceConnectionState == 'connected' || peer.iceConnectionState == 'completed')
56
- if (!pass) return env.setTimeout((_) => rs(true), 100) // keep looping util connected
57
- cb && cb()
58
- rs(false)
59
- }),
60
- )
61
-
62
- let readyToSendCandidateResolve
63
- let readyToSendCandidate = new env.Promise((rs) => (readyToSendCandidateResolve = rs))
64
-
65
- let initConnection = () => {
66
- let needConnect =
67
- !peer ||
68
- peer.connectionState === 'disconnected' ||
69
- peer.connectionState === 'closed' ||
70
- peer.connectionState === 'failed'
38
+ // to receive incomming call
39
+ realtime.subscribe([`webrtc.account.${accid}.agent.${agid}`])
71
40
 
72
- if (!needConnect) return
73
- peer = new env.RTCPeerConnection(servers)
74
- let mypeer = peer
75
-
76
- readyToSendCandidate = new env.Promise((rs) => (readyToSendCandidateResolve = rs))
77
- peer.ontrack = (event) => {
78
- if (peer != mypeer) return // outdated
79
- onTrack && onTrack(event)
80
- }
41
+ let initSession = (cb) => {
42
+ let connId = 'webrtc' + randomString(20)
43
+ let peer = new env.RTCPeerConnection(servers)
44
+ peers[connId] = peer
45
+ peer.ontrack = (event) => onTrack && onTrack(event)
46
+ waitForRemoteDescription[connId] = new env.Promise((rs) => (readyToSendCandidateResolve[connId] = rs))
81
47
 
82
48
  peer.onicecandidate = (event) => {
83
49
  if (!event.candidate) return
84
- if (peer != mypeer) return // outdated
85
50
  let url = `${apiUrl}ice-candidates?x-access-token=${access_token}&connection_id=${connId}`
86
51
  callAPI('post', url, jsonify(event.candidate))
87
52
  }
88
53
 
89
54
  peer.onnegotiationneeded = (ev) => console.log('NNNEED RE-NEGOTIATION', ev)
90
- console.log('NEW PERR')
55
+ realtime
56
+ .subscribe([
57
+ `webrtc.account.${accid}.agent.${agid}`,
58
+ `webrtc.account.${accid}.agent.${agid}.webrtc_connection.${connId}`,
59
+ ])
60
+ .then((err) => {
61
+ // create peer connection
62
+
63
+ cb(err, connId)
64
+ })
91
65
  }
92
66
 
67
+ let checkIceConnected = (peer, cb) =>
68
+ flow.loop(
69
+ () =>
70
+ new env.Promise((rs) => {
71
+ let pass = peer && (peer.iceConnectionState == 'connected' || peer.iceConnectionState == 'completed')
72
+ if (!pass) return env.setTimeout((_) => rs(true), 100) // keep looping util connected
73
+ cb && cb()
74
+ rs(false)
75
+ }),
76
+ )
77
+
78
+ let readyToSendCandidateResolve = {}
79
+ let waitForRemoteDescription = {}
93
80
  let stopStream = (stream) => {
94
81
  if (!stream) return
95
82
  stream.getTracks().forEach((track) => {
@@ -98,64 +85,109 @@ function WebRTCConn(options) {
98
85
  })
99
86
  }
100
87
 
101
- // auto tear down when byte received is zero
102
- // use to clear peer when there's no byte sent around 100 seconds
103
- let receivedBytes = []
104
- let check = () => {
105
- let url = `${apiUrl}calls?x-access-token=${access_token}&connection_id=${connId}`
88
+ let syncCallWithServer = () => {
89
+ let url = `${apiUrl}calls?x-access-token=${access_token}`
106
90
  callAPI('get', url, undefined, function (body, code) {
107
- if (code == 200) {
108
- body = parseJSON(body) || []
109
- let serverCall = {}
110
- body.map && body.map((call) => (serverCall[call.call_id] = call))
111
-
112
- // not found
113
- Object.keys(activeCalls).map((callid) => {
114
- let call = activeCalls[callid]
115
- if (!serverCall[callid] && call && call.status != 'ended')
116
- publish({type: 'call_ended', data: {call_info: {hanup_code: 'outdated', call_id: callid}}})
117
- })
118
- activeCalls = serverCall
119
- }
91
+ if (code != 200) return
92
+ body = parseJSON(body) || []
93
+ let newCalls = {}
94
+ if (!body.map) return
95
+ body.map((call) => {
96
+ newCalls[call.call_id] = call
97
+ })
120
98
 
121
- if (!peer) return
122
- peer.getStats(null).then((stats) => {
123
- let statsOutput = ''
124
- let receivedByte = 0
125
- stats.forEach((report) => {
126
- if (report.type === 'inbound-rtp' && report.kind === 'audio') {
127
- receivedByte += report.bytesReceived || 0
128
- }
129
- if (report.type === 'inbound-rtp' && report.kind === 'video') {
130
- receivedByte += report.bytesReceived || 0
131
- }
132
- })
133
- receivedBytes.push(receivedByte)
134
- while (receivedBytes.length > 10) receivedBytes.shift()
135
- let diff = receivedBytes.filter((n) => n != receivedByte)
136
- if (diff.length == 0 && receivedBytes.length >= 10 && Object.keys(activeCalls).length == 0) {
137
- // could tear down
138
- peer.close()
139
- peer = undefined
99
+ // end all local calls that not found on the server
100
+ Object.keys(serverCalls).map((i) => {
101
+ let call = serverCalls[i]
102
+ if (!call) return delete serverCalls[i]
103
+ let callid = call.call_id
104
+ if (newCalls[callid]) return
105
+
106
+ // calls that are missing on the server
107
+ newCalls[callid] = call
108
+ if (!call.ended) call.ended = env.Date.now()
109
+ if (!call.hangup_code) call.hangup_code = 'cancel'
110
+ if (call.status != 'ended') {
111
+ call.status = 'ended'
112
+ publish({type: 'call_ended', data: {call_info: {hanup_code: 'outdated', call_id: callid}}})
113
+ }
114
+ })
115
+
116
+ serverCalls = {}
117
+ Object.values(newCalls).map((call) => {
118
+ if (!call) return false
119
+ if (call.status == 'ended') {
120
+ // receive ended call
121
+ serverCalls[call.call_id] = call
122
+ return
123
+ }
124
+ // keep all dialing incomming calls
125
+ if (call.status == 'dialing' && (call.direction == 'incoming' || call.direction == 'inbound')) {
126
+ serverCalls[call.call_id] = call
127
+ return
140
128
  }
129
+
130
+ let connId = call.device_id
131
+ let peer = peers[connId]
132
+ if (!peer) return false // ignore active call that not in our peer
133
+ call2Connection[call.call_id] = connId
134
+ serverCalls[call.call_id] = call
135
+ return true
141
136
  })
142
137
  })
143
138
  }
144
- check()
145
- env.setInterval(check, 30000)
139
+ syncCallWithServer()
140
+ env.setInterval(syncCallWithServer, 10000)
141
+
142
+ let cleanEndedCall = () => {
143
+ Object.keys(endRequest).map((callid) => {
144
+ if (env.Date.now() - endRequest[callid] > 60000) delete endRequest[callid]
145
+ })
146
+
147
+ Object.keys(answerRequest).map((callid) => {
148
+ if (env.Date.now() - endRequest[callid] > 60000) delete answerRequest[callid]
149
+ })
150
+
151
+ Object.keys(serverCalls).map((callid) => {
152
+ let call = serverCalls[callid]
153
+ if (!call) return delete serverCalls[callid]
154
+ if (call.status != 'ended') return
155
+ if (env.Date.now() - call.ended > 60000) delete serverCalls[callid]
156
+ })
157
+
158
+ Object.keys(call2Connection).map((callid) => {
159
+ let call = this.matchCall(callid)
160
+ if (!call) return delete call2Connection[callid]
161
+ if (call.status != 'ended') return
162
+ if (env.Date.now() - call.ended > 60000) delete call2Connection[callid]
163
+ })
164
+ }
165
+ env.setInterval(cleanEndedCall, 120000)
146
166
 
147
167
  let publish = (ev) => {
148
168
  if (ev.type == 'call_ended') {
149
- let callid = lo.get(ev, 'data.call_info.call_id')
150
- stopStream(callStream[callid])
151
- delete callStream[callid]
169
+ let callid = ev.data && ev.data.call_info && ev.data.call_info.call_id
170
+ let connId = call2Connection[callid]
171
+ let peer = peers[connId]
172
+ if (peer) {
173
+ delete peers[connId]
174
+ peer.close()
175
+ }
176
+ stopStream(streams[connId])
177
+ // do not clean up this now or some functions cannot work after the call ended
178
+ // DONOT: delete call2Connection[callid]
179
+ delete streams[connId]
152
180
  }
181
+
182
+ let direction = ev.data && ev.data.call_info && ev.data.call_info.direction
183
+ if (direction == 'incoming') ev.data.call_info.direction = 'inbound'
184
+ if (direction == 'outgoing') ev.data.call_info.direction = 'outbound'
153
185
  onEvent && onEvent(ev)
154
186
  }
155
187
 
156
188
  this.matchCall = (callid) => {
157
189
  if (!callid || callid == '*' || callid == '-') {
158
- let calls = Object.assign({}, activeCalls)
190
+ let calls = Object.assign({}, serverCalls)
159
191
  Object.keys(dialingRequest).map((callid) => {
160
192
  let call = calls[callid]
161
193
  if (!call && dialingRequest[callid]) {
@@ -180,10 +212,16 @@ function WebRTCConn(options) {
180
212
  if (!call) return
181
213
  call.answer_requested = answerRequest[callid]
182
214
  })
215
+
216
+ Object.values(calls).map((call) => {
217
+ let direction = call && call.direction
218
+ if (direction == 'incoming') call.direction = 'inbound'
219
+ if (direction == 'outgoing') call.direction = 'outbound'
220
+ })
183
221
  return calls
184
222
  }
185
223
 
186
- let call = activeCalls[callid]
224
+ let call = serverCalls[callid]
187
225
  if (!call && dialingRequest[callid]) call = dialingRequest[callid]
188
226
  if (endRequest[callid] && call && call.status != 'ended') {
189
227
  call = Object.assign({}, call, {
@@ -194,47 +232,54 @@ function WebRTCConn(options) {
194
232
  })
195
233
  }
196
234
  if (answerRequest[callid]) call = Object.assign({}, call, {answer_requested: answerRequest[callid]})
235
+ let direction = call && call.direction
236
+ if (direction == 'incoming') call.direction = 'inbound'
237
+ if (direction == 'outgoing') call.direction = 'outbound'
238
+
197
239
  return call
198
240
  }
199
241
 
200
242
  realtime.onEvent((ev) => {
201
243
  if (!ev || !ev.type || !ev.data) return
202
- // if (ev.type.startsWith('call')) console.log('EEEEEEE', ev)
203
244
  if (env._is_webrtc_local && ev.user_id != '8') return // local
204
245
  if (!env._is_webrtc_local && ev.user_id != '9') return // prod
205
246
 
206
247
  if (ev.type == 'webrtc_candidate_added') {
207
- let ice_candidate = ev.data.ice_candidate
248
+ let ice_candidate = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.ice_candidate
249
+ let connId = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.connection_id
250
+ if (!connId) return
208
251
  if (!ice_candidate) return
209
- return readyToSendCandidate.then(() => peer && peer.addIceCandidate(parseJSON(ice_candidate)))
252
+ let peer = peers[connId]
253
+
254
+ return waitForRemoteDescription[connId].then(() => peer && peer.addIceCandidate(parseJSON(ice_candidate)))
210
255
  }
211
256
 
212
257
  if (ev.type == 'webrtc_negotiation_offered') {
213
258
  console.log('RT/RECEIVE OFFERED NEGOTIATION', ev)
259
+ let jsonoffer = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.offer
260
+ let connId = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.connection_id
261
+ let peer = peers[connId]
214
262
  if (!peer) return
215
263
  let negid = ev.id
216
- if (!ev.data.ice_candidate) return
217
- let offer = parseJSON(ev.data.ice_candidate)
264
+ // let negid = ev && ev.data && ev.data.webrtc_message && ev.data.webrtc_message.negotiation_id
265
+ let offer = parseJSON(jsonoffer)
266
+ if (!offer) return
218
267
 
219
268
  peer
220
269
  .setRemoteDescription(offer)
221
270
  .then(() => {
222
- readyToSendCandidateResolve()
223
- if (pendingStream && pendingStream != currentStream) {
224
- currentStream = pendingStream
225
- currentStream.getTracks().forEach((track) => {
226
- let replaced = false
227
- peer.getSenders().forEach((sender) => {
228
- if (!sender.track) return
229
- if (sender.track.kind != track.kind) return
230
- sender.replaceTrack(track)
231
- replaced = true
232
- })
233
-
234
- if (!replaced) peer.addTrack(track, currentStream)
271
+ readyToSendCandidateResolve[connId]()
272
+ let stream = streams[connId]
273
+ stream && stream.getTracks().forEach((track) => {
274
+ let replaced = false
275
+ peer.getSenders().forEach((sender) => {
276
+ if (!sender.track) return
277
+ if (sender.track.kind != track.kind) return
278
+ sender.replaceTrack(track)
279
+ replaced = true
235
280
  })
236
- }
237
- pendingStream = undefined
281
+ if (!replaced) peer.addTrack(track, stream)
282
+ })
238
283
  return peer.createAnswer()
239
284
  })
240
285
  .then((answer) =>
@@ -251,18 +296,28 @@ function WebRTCConn(options) {
251
296
  let call_info = ev.data.call_info || {}
252
297
  let callid = call_info.call_id
253
298
  if (!callid) return
299
+ let type = ev.type
300
+ if (type === 'call_invited') {
301
+ if (ev.type === 'call_invited' && agid == call_info.from_number) return // ignore our own invite
302
+ serverCalls[callid] = call_info
303
+ return publish(ev)
304
+ }
305
+
306
+ if (type == 'call_ended') {
307
+ serverCalls[callid] = call_info
308
+ return publish(ev)
309
+ }
254
310
 
255
311
  if (ev.type === 'call_invite_expired') {
256
- let device_id = call_info.device_id
257
- if (device_id == connId) return // ignore since we have accepted the call
312
+ if (Object.keys(peers).find((cid) => cid == call_info.device_id)) return // this call invitation is expired by us => ignore
258
313
  publish(Object.assign({}, ev, {type: 'call_ended'})) // fake ended call
259
- delete activeCalls[callid]
314
+ return
260
315
  }
261
316
 
262
- let type = ev.type
263
- if (type == 'call_ended' || type == 'call_ringing' || type == 'call_joined' || type === 'call_invited') {
264
- if (ev.type === 'call_invited' && agid == call_info.from_number) return // ignore our own invite
265
- activeCalls[callid] = call_info
317
+ let connId = call_info.device_id
318
+ if (!Object.keys(peers).find((cid) => cid == connId)) return
319
+ if (type == 'call_ringing' || type == 'call_joined') {
320
+ serverCalls[callid] = call_info
266
321
  return publish(ev)
267
322
  }
268
323
  })
@@ -273,7 +328,6 @@ function WebRTCConn(options) {
273
328
  let call = {
274
329
  account_id: accid,
275
330
  call_id: callid,
276
- device_id: connId,
277
331
  direction: 'outbound',
278
332
  member_id: agid,
279
333
  started: now,
@@ -283,30 +337,32 @@ function WebRTCConn(options) {
283
337
  dialingRequest[callid] = call
284
338
  publish({type: 'call_ringing', data: {call_info: {call_id: callid}}}) // fake dialing
285
339
  return new env.Promise((rs) => {
286
- streamPm.then(stream => {
287
- this.joinCall(callid, stream, (err) => {
288
- if (err) {
289
- delete dialingRequest[callid]
290
- endRequest[callid] = env.Date.now()
291
- publish({type: 'call_ended', data: {call_info: {call_id: callid}}})
292
- return rs({error: err})
293
- }
294
-
295
- let url = `${apiUrl}call?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number=${number}&from_number=${fromnumber}`
296
- callAPI('post', url, undefined, (body, code) => {
297
- if (code != 200) {
340
+ streamPm
341
+ .then((stream) => {
342
+ this.joinCall(callid, stream, (err, connId) => {
343
+ if (err) {
298
344
  delete dialingRequest[callid]
299
345
  endRequest[callid] = env.Date.now()
300
346
  publish({type: 'call_ended', data: {call_info: {call_id: callid}}})
301
347
  return rs({error: err})
302
348
  }
303
- body = parseJSON(body)
304
- if (body) activeCalls[callid] = body
305
- delete dialingRequest[callid]
306
- rs({body})
349
+
350
+ let url = `${apiUrl}call?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number=${number}&from_number=${fromnumber}`
351
+ callAPI('post', url, undefined, (body, code) => {
352
+ if (code != 200) {
353
+ delete dialingRequest[callid]
354
+ endRequest[callid] = env.Date.now()
355
+ publish({type: 'call_ended', data: {call_info: {call_id: callid}}})
356
+ return rs({error: err})
357
+ }
358
+ body = parseJSON(body)
359
+ if (body) serverCalls[callid] = body
360
+ delete dialingRequest[callid]
361
+ rs({body})
362
+ })
307
363
  })
308
364
  })
309
- })
365
+ .catch((err) => rs({error: err}))
310
366
  })
311
367
  }
312
368
 
@@ -314,47 +370,48 @@ function WebRTCConn(options) {
314
370
  if (!callid) return
315
371
  endRequest[callid] = env.Date.now()
316
372
 
317
- let call = activeCalls[callid]
373
+ let call = serverCalls[callid]
318
374
  if (!call) {
319
375
  call = {call_id: callid}
320
- activeCalls[callid] = call
376
+ serverCalls[callid] = call
321
377
  }
322
378
  call.status = 'ended'
323
379
  call.ended = env.Date.now()
324
380
  publish({type: 'call_ended', data: {call_info: {call_id: callid}}})
325
-
326
- callAPI('post', `${apiUrl}hangup?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}`)
381
+ callAPI('post', `${apiUrl}hangup?x-access-token=${access_token}&call_id=${callid}`)
327
382
  }
328
383
 
329
384
  this.listenCall = (callid, cb) => {
330
- let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}`
331
- makeSure(() => callAPI('post', url, undefined, (body, code) => (code != 200 ? cb(body) : cb())))
385
+ initSession((err, connId) => {
386
+ let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}`
387
+ callAPI('post', url, undefined, (body, code) => (code != 200 ? cb(body) : cb()))
388
+ })
332
389
  }
333
390
 
334
391
  this.joinCall = (callid, stream, cb) => {
335
- callStream[callid] = stream
336
392
  console.time('makecall' + callid)
337
- makeSure(() => {
338
- pendingStream = stream
393
+ initSession((err, connId) => {
394
+ call2Connection[callid] = connId
395
+ streams[connId] = stream
339
396
  let url = `${apiUrl}listen?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&talk=true`
340
397
  callAPI('post', url, undefined, (body, code) => {
341
- if (code != 200) return cb(body)
398
+ if (code != 200) return cb(body, connId)
342
399
  console.timeEnd('makecall' + callid)
343
- if (code != 200) return cb(body)
344
- checkIceConnected(() => cb())
400
+ if (code != 200) return cb(body, connId)
401
+ checkIceConnected(peers[connId], () => cb(undefined, connId))
345
402
  })
346
403
  })
347
404
  }
348
405
 
349
406
  this.answerCall = (callid, stream) => {
350
- let call = activeCalls[callid]
407
+ let call = serverCalls[callid]
351
408
  if (call && call.status == 'ended') return Promise.resolve({error: 'call_ended'})
352
409
  let now = env.Date.now()
353
410
  if (answerRequest[callid]) return Promise.resolve({body: call})
354
411
  answerRequest[callid] = now
355
412
  publish({type: 'call_ringing', data: {call_info: {call_id: callid}}}) // fake dialing
356
413
  return new env.Promise((rs) => {
357
- this.joinCall(callid, stream, (err) => {
414
+ this.joinCall(callid, stream, (err, connId) => {
358
415
  if (err) {
359
416
  delete answerRequest[callid]
360
417
  endRequest[callid] = now
@@ -379,8 +436,11 @@ function WebRTCConn(options) {
379
436
  this.sendDtmf = (key, callid) =>
380
437
  new env.Promise((rs) => {
381
438
  if (!callid) return rs({body: {}})
439
+ let connId = call2Connection[callid]
440
+ if (!connId) return rs({body: {}})
441
+
382
442
  let url = `${apiUrl}dtml?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&key={key}`
383
- makeSure(() =>
443
+ initSession(() =>
384
444
  callAPI('post', url, undefined, (body, bode) => {
385
445
  body = parseJSON(body)
386
446
  return code != 200 ? rs({error: body}) : rs({body})
@@ -391,8 +451,10 @@ function WebRTCConn(options) {
391
451
  this.transferCall = (tonumber, callid) =>
392
452
  new env.Promise((rs) => {
393
453
  if (!callid) return rs({})
454
+ let connId = call2Connection[callid]
455
+ if (!connId) return rs({body: {}})
394
456
  let url = `${apiUrl}refer?x-access-token=${access_token}&connection_id=${connId}&call_id=${callid}&number={tonumber}`
395
- makeSure(() =>
457
+ initSession(() =>
396
458
  callAPI('post', url, undefined, (body, code) => {
397
459
  body = parseJSON(body)
398
460
  return code != 200 ? rs({error: body}) : rs({body})