@telegenta/webclient 2.0.11 → 3.0.0
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/README.md +102 -81
- package/demos/add_phone_number.html +8 -7
- package/demos/calldata_available.html +5 -5
- package/demos/embedded_config.html +95 -95
- package/demos/event_client.html +226 -226
- package/demos/phone_client.html +564 -558
- package/dist/telegenta.js +3627 -0
- package/dist/telegenta.min.js +1 -0
- package/docs/EventClient.html +3 -1093
- package/docs/InvalidMediaDevice.html +3 -0
- package/docs/InvalidOptions.html +3 -0
- package/docs/InvalidParameter.html +3 -0
- package/docs/LogHandler.html +7 -0
- package/docs/MediaNotPrepared.html +3 -0
- package/docs/NoActiveSession.html +3 -0
- package/docs/NotAllowed.html +3 -0
- package/docs/NotReady.html +3 -0
- package/docs/Phone.html +4 -2837
- package/docs/Request.html +3 -0
- package/docs/Requestor.html +3 -0
- package/docs/Session.html +3 -2599
- package/docs/UnableToConnect.html +3 -0
- package/docs/data/search.json +1 -0
- package/docs/event-client.js.html +346 -0
- package/docs/exceptions.js.html +94 -0
- package/docs/fonts/Inconsolata-Regular.ttf +0 -0
- package/docs/fonts/OpenSans-Regular.ttf +0 -0
- package/docs/fonts/WorkSans-Bold.ttf +0 -0
- package/docs/global.html +5 -4950
- package/docs/index.html +3 -66
- package/docs/logger.js.html +106 -0
- package/docs/phone-client.js.html +2155 -0
- package/docs/requestor.js.html +223 -0
- package/docs/scripts/core.js +726 -0
- package/docs/scripts/core.min.js +23 -0
- package/docs/scripts/resize.js +90 -0
- package/docs/scripts/search.js +265 -0
- package/docs/scripts/search.min.js +6 -0
- package/docs/scripts/{prettify → third-party}/Apache-License-2.0.txt +202 -202
- package/docs/scripts/third-party/fuse.js +9 -0
- package/docs/scripts/third-party/hljs-line-num-original.js +369 -0
- package/docs/scripts/third-party/hljs-line-num.js +1 -0
- package/docs/scripts/third-party/hljs-original.js +5171 -0
- package/docs/scripts/third-party/hljs.js +1 -0
- package/docs/scripts/third-party/popper.js +5 -0
- package/docs/scripts/third-party/tippy.js +1 -0
- package/docs/scripts/third-party/tocbot.js +672 -0
- package/docs/scripts/third-party/tocbot.min.js +1 -0
- package/docs/styles/clean-jsdoc-theme-base.css +1159 -0
- package/docs/styles/clean-jsdoc-theme-dark.css +412 -0
- package/docs/styles/clean-jsdoc-theme-light.css +482 -0
- package/docs/styles/clean-jsdoc-theme-scrollbar.css +30 -0
- package/docs/styles/clean-jsdoc-theme-without-scrollbar.min.css +1 -0
- package/docs/styles/clean-jsdoc-theme.min.css +1 -0
- package/package.json +46 -34
- package/babel.config.js +0 -16
- package/browser/telegenta.min.js +0 -1
- package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Bold-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Light-webfont.svg +0 -1831
- package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
- package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.svg +0 -1831
- package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Semibold-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Semibold-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-Semibold-webfont.ttf +0 -0
- package/docs/fonts/OpenSans-Semibold-webfont.woff +0 -0
- package/docs/fonts/OpenSans-SemiboldItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-SemiboldItalic-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-SemiboldItalic-webfont.ttf +0 -0
- package/docs/fonts/OpenSans-SemiboldItalic-webfont.woff +0 -0
- package/docs/scripts/linenumber.js +0 -25
- package/docs/scripts/prettify/lang-css.js +0 -2
- package/docs/scripts/prettify/prettify.js +0 -28
- package/docs/styles/jsdoc-default.css +0 -692
- package/docs/styles/prettify-jsdoc.css +0 -111
- package/docs/styles/prettify-tomorrow.css +0 -132
- package/embedded_client/embedded_phone.js +0 -325
- package/embedded_client/index.html +0 -201
- package/embedded_client/server.php +0 -28
- package/embedded_client/styles.css +0 -310
- package/embedded_client/tiny_rest_client.php +0 -73
- package/embedded_client/webphone_sprites.png +0 -0
- package/es5/event-client.js +0 -322
- package/es5/index.js +0 -44
- package/es5/phone-client.js +0 -1739
- package/src/event-client.js +0 -1
- package/src/index.js +0 -10
- package/src/phone-client.js +0 -1
- package/webpack.config.js +0 -39
|
@@ -0,0 +1,3627 @@
|
|
|
1
|
+
import EventEmitter from 'events';
|
|
2
|
+
import JsSIP from 'jssip';
|
|
3
|
+
|
|
4
|
+
/*eslint no-console: ["error", { allow: ["debug", "info", "warn", "error", "log"] }] */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Logging class for Telegenta library. Use this if you want to handle logs or control log level.
|
|
8
|
+
* If no handler is given, logs will be sent to console.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // Creating Phone object with custom log instance
|
|
12
|
+
* import {LogHandler, LOGLVL} from './telegenta.js';
|
|
13
|
+
* const myLogger = new LogHandler(LOGLVL.WARNING, null)
|
|
14
|
+
* const phone = new Telegenta.Phone(myLogger)
|
|
15
|
+
* ...
|
|
16
|
+
*/
|
|
17
|
+
class LogHandler {
|
|
18
|
+
/** Create new log handler
|
|
19
|
+
* @param {LOGLVL} level - Log level
|
|
20
|
+
* @param {function} handler - External handler function to call on new log entries
|
|
21
|
+
*/
|
|
22
|
+
constructor(level = 2, handler = null) {
|
|
23
|
+
this.extHandler = handler;
|
|
24
|
+
this.level = level;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
debug(...args) {
|
|
28
|
+
this._doLog(LOGLVL$1.DEBUG, args);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
info(...args) {
|
|
32
|
+
this._doLog(LOGLVL$1.INFO, args);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
warn(...args) {
|
|
36
|
+
this._doLog(LOGLVL$1.WARN, args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
warning(...args) {
|
|
40
|
+
this._doLog(LOGLVL$1.WARN, args);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
error(...args) {
|
|
44
|
+
this._doLog(LOGLVL$1.ERROR, args);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_doLog(level, args) {
|
|
48
|
+
// Check log level
|
|
49
|
+
if (level > this.level) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If only one element, discard array (gives better output to console)
|
|
54
|
+
if (args.length === 1) {
|
|
55
|
+
args = args[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Use external handler?
|
|
59
|
+
if (this.extHandler) {
|
|
60
|
+
this.extHandler(LOGLVLSTR[level], args);
|
|
61
|
+
} else {
|
|
62
|
+
switch (level) {
|
|
63
|
+
case LOGLVL$1.DEBUG:
|
|
64
|
+
console.debug(args);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case LOGLVL$1.INFO:
|
|
68
|
+
console.info(args);
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case LOGLVL$1.WARN:
|
|
72
|
+
console.warn(args);
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case LOGLVL$1.ERROR:
|
|
76
|
+
console.error(args);
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
default:
|
|
80
|
+
console.log(args);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Log levels
|
|
88
|
+
* @typedef LOGLVL {number}
|
|
89
|
+
* @property {number} ERROR 0 Error messages
|
|
90
|
+
* @property {number} WARN 1 Warning meesages
|
|
91
|
+
* @property {number} INFO 2 Informational messages (default)
|
|
92
|
+
* @property {number} DEBUG 3 Debug messages
|
|
93
|
+
*/
|
|
94
|
+
const LOGLVL$1 = {
|
|
95
|
+
DEBUG: 3,
|
|
96
|
+
INFO: 2,
|
|
97
|
+
WARN: 1,
|
|
98
|
+
ERROR: 0,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const LOGLVLSTR = {
|
|
102
|
+
3: "DEBUG",
|
|
103
|
+
2: "INFO ",
|
|
104
|
+
1: "WARN ",
|
|
105
|
+
0: "ERROR",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Exception raised if Phone media permission is not prepared / allowed
|
|
110
|
+
*/
|
|
111
|
+
class MediaNotPrepared extends Error {
|
|
112
|
+
constructor(message) {
|
|
113
|
+
super();
|
|
114
|
+
this.name = 'MediaNotPrepared';
|
|
115
|
+
this.message = message;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Exception raised if Phone is not ready to perform desired action
|
|
121
|
+
*/
|
|
122
|
+
class NotReady extends Error {
|
|
123
|
+
constructor(message) {
|
|
124
|
+
super();
|
|
125
|
+
this.name = 'NotReady';
|
|
126
|
+
this.message = message;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Exception raised if media device id given does not exists
|
|
132
|
+
* @property message - Error message
|
|
133
|
+
*/
|
|
134
|
+
class InvalidMediaDevice extends Error {
|
|
135
|
+
constructor(message) {
|
|
136
|
+
super();
|
|
137
|
+
this.name = 'InvalidMediaDevice';
|
|
138
|
+
this.message = message;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Exception raised if one or more options parsed is invalid
|
|
144
|
+
* @property param - Name of invalid options parameter
|
|
145
|
+
* @property message - Error message
|
|
146
|
+
*
|
|
147
|
+
*/
|
|
148
|
+
class InvalidOptions extends Error {
|
|
149
|
+
constructor(param, message) {
|
|
150
|
+
super();
|
|
151
|
+
this.name = 'InvalidOptions';
|
|
152
|
+
this.param = param;
|
|
153
|
+
this.message = message;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Exception raised if parameter given is invalid
|
|
159
|
+
* @property message - Error message
|
|
160
|
+
*/
|
|
161
|
+
class InvalidParameter extends Error {
|
|
162
|
+
constructor(message) {
|
|
163
|
+
super();
|
|
164
|
+
this.name = 'InvalidParameter';
|
|
165
|
+
this.message = message;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Exception raised if trying to perform actions that require active session
|
|
171
|
+
*/
|
|
172
|
+
class NoActiveSession extends Error {
|
|
173
|
+
constructor() {
|
|
174
|
+
super();
|
|
175
|
+
this.name = 'NoActiveSession';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Exception raised if unable to connect to service
|
|
181
|
+
*/
|
|
182
|
+
class UnableToConnect extends Error {
|
|
183
|
+
constructor() {
|
|
184
|
+
super();
|
|
185
|
+
this.name = 'UnableToConnect';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Exception raised operation is not allowed
|
|
191
|
+
*/
|
|
192
|
+
class NotAllowed extends Error {
|
|
193
|
+
constructor(message) {
|
|
194
|
+
super();
|
|
195
|
+
this.name = 'NotAllowed';
|
|
196
|
+
this.message = message;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* TOOLBOX
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
// Create a new UUID
|
|
205
|
+
function createUUID() {
|
|
206
|
+
let d = new Date().getTime();
|
|
207
|
+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
208
|
+
d += performance.now(); //use high-precision timer if available
|
|
209
|
+
}
|
|
210
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
211
|
+
let r = (d + Math.random() * 16) % 16 | 0;
|
|
212
|
+
d = Math.floor(d / 16);
|
|
213
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Compare properties of two objects. Comparison check if all properties in obj1 exists and has same values in obj2
|
|
218
|
+
function compareObjects(obj1, obj2) {
|
|
219
|
+
if ((!obj1) || (!obj2)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// console.info('OBJECT 1: '+JSON.stringify(obj1));
|
|
224
|
+
// console.info('OBJECT 2: '+JSON.stringify(obj2));
|
|
225
|
+
|
|
226
|
+
const k2 = Object.keys(obj2);
|
|
227
|
+
return Object.keys(obj1).every(function (k) {
|
|
228
|
+
// Check key exists first
|
|
229
|
+
if (k2.indexOf(k) === -1) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (obj1[k] === obj2[k]);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Return duration as formatted string
|
|
238
|
+
function createDurationString(milliSeconds) {
|
|
239
|
+
const seconds = Math.floor(milliSeconds / 1000);
|
|
240
|
+
|
|
241
|
+
let s = '';
|
|
242
|
+
|
|
243
|
+
// Days
|
|
244
|
+
if (seconds / 86400 >= 1) {
|
|
245
|
+
s = s + Math.floor(seconds / 86400) + 'd ';
|
|
246
|
+
}
|
|
247
|
+
// Hour
|
|
248
|
+
if (seconds / 3600 >= 1) {
|
|
249
|
+
s = s + Math.floor(seconds / 3600) % 24 + 'h ';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Seconds
|
|
253
|
+
if (seconds >= 0) {
|
|
254
|
+
s = s + ("00" + Math.floor(seconds / 60) % 60).slice(-2) + ':';
|
|
255
|
+
s = s + ("00" + (seconds % 60)).slice(-2);
|
|
256
|
+
} else {
|
|
257
|
+
s = '00:00';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return s;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Browser checks
|
|
264
|
+
function browserIsOpera() {
|
|
265
|
+
return ((navigator.userAgent.indexOf("Opera") || navigator.userAgent.indexOf('OPR')) !== -1)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function browserIsChrome() {
|
|
269
|
+
return (navigator.userAgent.indexOf("Chrome") !== -1)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function browserIsFirefox() {
|
|
273
|
+
return (navigator.userAgent.indexOf("Firefox") !== -1)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function browserIsSafari() {
|
|
277
|
+
return (navigator.userAgent.indexOf("Safari") !== -1)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function browserIsEdge() {
|
|
281
|
+
return (navigator.userAgent.indexOf("Edge") !== -1)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Request timeout after 10 sec
|
|
285
|
+
const REQUEST_TIMEOUT = 10000;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Request class passed to event listeners
|
|
289
|
+
* Can be used to fulfill request and receive request parameters (data property)
|
|
290
|
+
*/
|
|
291
|
+
class Request {
|
|
292
|
+
constructor(owner, socket, request) {
|
|
293
|
+
this.socket = socket;
|
|
294
|
+
this._owner = owner;
|
|
295
|
+
this._request = request;
|
|
296
|
+
this._handled = false;
|
|
297
|
+
this.data = request.data;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve request (promise will be resolved successfully on remote)
|
|
302
|
+
* @param data
|
|
303
|
+
*/
|
|
304
|
+
resolve(data = null) {
|
|
305
|
+
this._assertSingleRequest();
|
|
306
|
+
try {
|
|
307
|
+
this.socket.send(JSON.stringify({ type: "_ACK", id: this._request.id, data: data }));
|
|
308
|
+
} catch (e) {
|
|
309
|
+
this._owner.log.warning(`Unable to resolve request ${this._request.id}: ${e}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Reject request (promise will be rejected on remote)
|
|
315
|
+
* @param reason
|
|
316
|
+
*/
|
|
317
|
+
reject(reason = null) {
|
|
318
|
+
this._assertSingleRequest();
|
|
319
|
+
try {
|
|
320
|
+
this.socket.send(JSON.stringify({ type: "_NACK", id: this._request.id, reason: reason }));
|
|
321
|
+
} catch (e) {
|
|
322
|
+
this._owner.log.warning(`Unable to reject request ${this._request.id}: ${e}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Make sure request is only fulfilled once
|
|
327
|
+
_assertSingleRequest() {
|
|
328
|
+
if (this._handled) {
|
|
329
|
+
throw new NotAllowed(`Request ${this._request.id} already fulfilled`);
|
|
330
|
+
}
|
|
331
|
+
this._handled = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* This class handles async communication on websockets and implements promises for requests
|
|
337
|
+
*/
|
|
338
|
+
class Requestor extends EventEmitter {
|
|
339
|
+
constructor(logger = null) {
|
|
340
|
+
super();
|
|
341
|
+
this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
|
|
342
|
+
this._pendingRequests = [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Receive message from websocket
|
|
347
|
+
* @param socket
|
|
348
|
+
* @param message
|
|
349
|
+
*/
|
|
350
|
+
receiveMessage(socket, message) {
|
|
351
|
+
this.log.debug("Received message: " + message);
|
|
352
|
+
|
|
353
|
+
let msg;
|
|
354
|
+
try {
|
|
355
|
+
msg = JSON.parse(message);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
this.log.warning("Unable to parse message: " + message);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Validate msg fields
|
|
362
|
+
if (!msg.hasOwnProperty("type") || !msg.hasOwnProperty("id")) {
|
|
363
|
+
this.log.warning(`Invalid message received: ${msg} - missing type or id`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Is it a ACK or NACK response?
|
|
368
|
+
if (msg.type === "_ACK" || msg.type === "_NACK") {
|
|
369
|
+
// Find pending request first
|
|
370
|
+
const request = this.findPendingRequest(msg.id);
|
|
371
|
+
if (!request) {
|
|
372
|
+
this.log.warning(`Received message with invalid request id: ${msg.id} - timed out?`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (msg.type === "_ACK") {
|
|
377
|
+
this.log.debug(`Received ACK with id=${msg.id}`);
|
|
378
|
+
request.resolve(msg.data);
|
|
379
|
+
} else if (msg.type === "_NACK") {
|
|
380
|
+
this.log.debug(`Received NACK with id=${msg.id}`);
|
|
381
|
+
request.reject(msg.reason);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.removePendingRequest(msg.id);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Is it PING request?
|
|
389
|
+
if (msg.type === "_PING") {
|
|
390
|
+
this.log.debug("Received ping, sending pong (ACK)");
|
|
391
|
+
socket.send(
|
|
392
|
+
JSON.stringify({
|
|
393
|
+
type: "_ACK",
|
|
394
|
+
id: msg.id,
|
|
395
|
+
data: "pong",
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
this.log.debug(`Received new request of type '${msg.type}' with id=${msg.id}`);
|
|
402
|
+
if (!this.emit(msg.type, new Request(this, socket, msg))) {
|
|
403
|
+
this.log.warning("Received request with no listeners, rejecting");
|
|
404
|
+
socket.send(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
type: "_NACK",
|
|
407
|
+
id: msg.id,
|
|
408
|
+
reason: "No handlers for that request type",
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Send a request
|
|
416
|
+
* @param socket Socket to send request
|
|
417
|
+
* @param type Request type name
|
|
418
|
+
* @param data Request Payload
|
|
419
|
+
* @throws InvalidParameter - If one or more parameters specified is invalid
|
|
420
|
+
* @returns promise Fulfilled once request has been handled on remote or rejected on error
|
|
421
|
+
*/
|
|
422
|
+
sendRequest(socket, type, data = {}) {
|
|
423
|
+
if (!socket || !type) {
|
|
424
|
+
throw new InvalidParameter("You must specify socket and request type");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Build and send request message
|
|
428
|
+
let r = { type: type, id: createUUID(), data: data };
|
|
429
|
+
this.log.debug(`Sending request type '${type}' with id ${r.id}`);
|
|
430
|
+
try {
|
|
431
|
+
socket.send(JSON.stringify(r));
|
|
432
|
+
} catch (e) {
|
|
433
|
+
this.log.warning(`Unable to send request ${r.id} socket error: ${e}`);
|
|
434
|
+
return new Promise(function (resolve, reject) {
|
|
435
|
+
reject(`Unable to send request: ${e}`);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Return promise and push to pending request list with a timeout
|
|
440
|
+
const self = this;
|
|
441
|
+
return new Promise(function (resolve, reject) {
|
|
442
|
+
self._pendingRequests.push({
|
|
443
|
+
request: r,
|
|
444
|
+
resolve: resolve,
|
|
445
|
+
reject: reject,
|
|
446
|
+
timeoutObj: setTimeout(function () {
|
|
447
|
+
self.log.warning(`Timeout on request ${r.id}, rejecting!`);
|
|
448
|
+
self.removePendingRequest(r.id);
|
|
449
|
+
reject("Request timed out");
|
|
450
|
+
}, REQUEST_TIMEOUT),
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Send a ping request (for keep alives)
|
|
457
|
+
* @param socket
|
|
458
|
+
* @returns promise Promise resolved on reply
|
|
459
|
+
*/
|
|
460
|
+
ping(socket) {
|
|
461
|
+
this.log.debug("Sending ping");
|
|
462
|
+
return this.sendRequest(socket, "_PING");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Remove a request from pending queue
|
|
467
|
+
* @param requestId
|
|
468
|
+
* @returns boolean Removal outcome (true = removed)
|
|
469
|
+
*/
|
|
470
|
+
removePendingRequest(requestId) {
|
|
471
|
+
for (let idx in this._pendingRequests) {
|
|
472
|
+
if (this._pendingRequests[idx].request.id === requestId) {
|
|
473
|
+
clearTimeout(this._pendingRequests[idx].timeoutObj);
|
|
474
|
+
this._pendingRequests.splice(idx, 1);
|
|
475
|
+
this.log.debug(`Removed request id: ${requestId}`);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
this.log.warning(`Unable to remove request ${requestId} => not found`);
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Get index og pending request
|
|
486
|
+
* @param requestId
|
|
487
|
+
* @returns object Return request from pending request array or null if not found
|
|
488
|
+
*/
|
|
489
|
+
findPendingRequest(requestId) {
|
|
490
|
+
for (let req of this._pendingRequests) {
|
|
491
|
+
if (req.request.id === requestId) {
|
|
492
|
+
return req;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/*
|
|
500
|
+
* EVENT CLASS
|
|
501
|
+
*
|
|
502
|
+
* AMQP Message structure
|
|
503
|
+
* ----------------------
|
|
504
|
+
* event_name
|
|
505
|
+
* customer_id
|
|
506
|
+
* partner_id
|
|
507
|
+
* payload
|
|
508
|
+
* resource_name (only resource_changed)
|
|
509
|
+
* call_token (only calldata_available)
|
|
510
|
+
*
|
|
511
|
+
*/
|
|
512
|
+
// Keep alive in seconds
|
|
513
|
+
const PING_INTERVAL = 50;
|
|
514
|
+
|
|
515
|
+
const ES_DEFAULT_SERVER_URL = "wss://api.telegenta.com/es/";
|
|
516
|
+
|
|
517
|
+
// Starting retry interval
|
|
518
|
+
const RETRY_INTERVAL_START = 1 + Math.random();
|
|
519
|
+
|
|
520
|
+
// Maximum reconnect time
|
|
521
|
+
const RETRY_MAX_BACKOFF = 300;
|
|
522
|
+
|
|
523
|
+
class EventSubscription {
|
|
524
|
+
constructor(id, specification, callback) {
|
|
525
|
+
this.id = id;
|
|
526
|
+
this.specification = specification;
|
|
527
|
+
this.callback = callback;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
doCallback(context, payload) {
|
|
531
|
+
return this.callback.apply(context, [payload]);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Class is responsible for handling real time server events over websockets <br>
|
|
537
|
+
* Using this class you can subscribe to a list of useful remote events
|
|
538
|
+
*
|
|
539
|
+
* @param {LogHandler} [logger] - Custom log instance
|
|
540
|
+
*
|
|
541
|
+
*/
|
|
542
|
+
class EventClient {
|
|
543
|
+
constructor(logger = null) {
|
|
544
|
+
this._socket = null;
|
|
545
|
+
this._previousSubscriptions = [];
|
|
546
|
+
this._activeSubscriptions = [];
|
|
547
|
+
this._pingTimerObj = null;
|
|
548
|
+
this._autoReconnect = false;
|
|
549
|
+
this._reconnectRetrySeconds = RETRY_INTERVAL_START;
|
|
550
|
+
this.log = logger ? logger : new LogHandler(LOGLVL.ERROR);
|
|
551
|
+
this._requestor = new Requestor(this.log);
|
|
552
|
+
|
|
553
|
+
this._requestor.on("event", (request) => {
|
|
554
|
+
this._handleIncomingEvent(request);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Establish a connecting to the event service
|
|
560
|
+
* @param {string=} Event service address URL
|
|
561
|
+
* @returns object Connection promise
|
|
562
|
+
*/
|
|
563
|
+
connect(server_address = null) {
|
|
564
|
+
if (!server_address) {
|
|
565
|
+
server_address = ES_DEFAULT_SERVER_URL;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
this._serverAddress = server_address;
|
|
569
|
+
this._previousSubscriptions = [];
|
|
570
|
+
this._activeSubscriptions = [];
|
|
571
|
+
return this._connectServer();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Disconnect from event service and clear all active subscriptions
|
|
576
|
+
* @throws NoActiveSession if not connected
|
|
577
|
+
*/
|
|
578
|
+
disconnect() {
|
|
579
|
+
if (this._socket.readyState !== 1) {
|
|
580
|
+
throw new NoActiveSession();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
this._activeSubscriptions = [];
|
|
584
|
+
this._autoReconnect = false;
|
|
585
|
+
this._socket.close();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Return service connection state
|
|
590
|
+
* @returns {boolean}
|
|
591
|
+
*/
|
|
592
|
+
isConnected() {
|
|
593
|
+
if (this._socket) {
|
|
594
|
+
return this._socket.readyState === 1;
|
|
595
|
+
} else {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Subscribe to event
|
|
602
|
+
* @param eventSpecification object Event parameters (please check docs for further description)
|
|
603
|
+
* @param callback object Callback function (will be called when event is received)
|
|
604
|
+
* @throws InvalidParameter
|
|
605
|
+
* @returns object Promise on subscription
|
|
606
|
+
*/
|
|
607
|
+
subscribe(eventSpecification, callback) {
|
|
608
|
+
if (!eventSpecification || !callback) {
|
|
609
|
+
throw new InvalidParameter("You must specify both event spec and callback function");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!eventSpecification.hasOwnProperty("event_name")) {
|
|
613
|
+
throw new InvalidParameter("Your event specification must contain event name");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!this.isConnected()) {
|
|
617
|
+
throw new NoActiveSession();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const self = this;
|
|
621
|
+
return new Promise(function (resolve, reject) {
|
|
622
|
+
self._requestor.sendRequest(self._socket, "subscribe", eventSpecification).then(
|
|
623
|
+
function (subscriptionId) {
|
|
624
|
+
self.log.debug(`Subscription to '${eventSpecification.event_name}' was successful, ID = ${subscriptionId}`);
|
|
625
|
+
self._activeSubscriptions.push(new EventSubscription(subscriptionId, eventSpecification, callback));
|
|
626
|
+
resolve(subscriptionId);
|
|
627
|
+
},
|
|
628
|
+
function (reason) {
|
|
629
|
+
self.log.warning(`Subscription to '${eventSpecification.event_name}' failed: ${reason}`);
|
|
630
|
+
reject(reason);
|
|
631
|
+
},
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Unsubscribe event messages
|
|
638
|
+
* @param subscriptionId Object Event specification to unsubscribe (must match specification used on subscribe)
|
|
639
|
+
* @throws InvalidParameter
|
|
640
|
+
* @returns object Promise on unsubscribe
|
|
641
|
+
*/
|
|
642
|
+
unsubscribe(subscriptionId) {
|
|
643
|
+
if (!subscriptionId) {
|
|
644
|
+
throw new InvalidParameter("You must specify subscription id");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (!this.isConnected()) {
|
|
648
|
+
throw new NoActiveSession();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.log.info("Unsubscribe id: " + subscriptionId);
|
|
652
|
+
|
|
653
|
+
const self = this;
|
|
654
|
+
return new Promise(function (resolve, reject) {
|
|
655
|
+
self._requestor.sendRequest(self._socket, "unsubscribe", { id: subscriptionId }).then(
|
|
656
|
+
(data) => {
|
|
657
|
+
self.log.info(`Unsubscribe to '${subscriptionId}' was successful`);
|
|
658
|
+
self._removeSubscription(subscriptionId);
|
|
659
|
+
resolve(data);
|
|
660
|
+
},
|
|
661
|
+
(reason) => {
|
|
662
|
+
self.log.warning(`Remote unsubscribe to '${subscriptionId}' failed: ${reason}`);
|
|
663
|
+
reject(reason);
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Fire event message
|
|
671
|
+
* @param type Event type to fire
|
|
672
|
+
* @param payload Event payload
|
|
673
|
+
* @throws NoActiveSession
|
|
674
|
+
* @returns object Promise on event
|
|
675
|
+
*/
|
|
676
|
+
fireEvent(type, payload) {
|
|
677
|
+
if (!this.isConnected()) {
|
|
678
|
+
throw new NoActiveSession();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return new Promise((resolve, reject) => {
|
|
682
|
+
this._requestor.sendRequest(this._socket, type, payload).then(
|
|
683
|
+
(data) => {
|
|
684
|
+
this.log.info(`Event ${type} was successful`);
|
|
685
|
+
resolve(data);
|
|
686
|
+
},
|
|
687
|
+
(reason) => {
|
|
688
|
+
this.log.warning(`Event ${type} failed: ${reason}`);
|
|
689
|
+
reject(reason);
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Remove subscription from active subscriptions
|
|
696
|
+
_removeSubscription(subscriptionId) {
|
|
697
|
+
for (let idx in this._activeSubscriptions) {
|
|
698
|
+
if (this._activeSubscriptions[idx].id === subscriptionId) {
|
|
699
|
+
this.log.debug(`Removed local subscription: ${subscriptionId}`);
|
|
700
|
+
this._activeSubscriptions.splice(idx, 1);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
this.log.warning(`Unable to remove subscription ${subscriptionId} locally, not found?`);
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Connect to event service
|
|
710
|
+
_connectServer() {
|
|
711
|
+
this.log.info("Connecting to event server: " + this._serverAddress);
|
|
712
|
+
|
|
713
|
+
const self = this;
|
|
714
|
+
return new Promise(function (resolve, reject) {
|
|
715
|
+
if (self._socket) {
|
|
716
|
+
switch (self._socket.readyState) {
|
|
717
|
+
case 0:
|
|
718
|
+
reject("Unable to connect, already connecting");
|
|
719
|
+
return;
|
|
720
|
+
case 1:
|
|
721
|
+
reject("Unable to connect, already connected");
|
|
722
|
+
return;
|
|
723
|
+
case 2:
|
|
724
|
+
reject("Unable to connect, socket is still closing");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
self._socket = new WebSocket(self._serverAddress);
|
|
730
|
+
let was_connected = false;
|
|
731
|
+
|
|
732
|
+
// CONNECTION OPEN
|
|
733
|
+
self._socket.addEventListener("open", function (/*event*/) {
|
|
734
|
+
self.log.info("Successfully connected to event service");
|
|
735
|
+
|
|
736
|
+
was_connected = true;
|
|
737
|
+
// After successful connect we enable auto connect and reset reconnect timer
|
|
738
|
+
self._autoReconnect = true;
|
|
739
|
+
self._reconnectRetrySeconds = RETRY_INTERVAL_START;
|
|
740
|
+
|
|
741
|
+
// Hook up event for incoming messages to requestor
|
|
742
|
+
self._socket.addEventListener("message", function (event) {
|
|
743
|
+
self._requestor.receiveMessage(self._socket, event.data);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Start keep alive timer
|
|
747
|
+
clearTimeout(self._pingTimerObj);
|
|
748
|
+
self._pingTimerObj = setInterval(function () {
|
|
749
|
+
self._requestor.ping(self._socket).then(
|
|
750
|
+
function () {
|
|
751
|
+
// self.log.debug('Pong');
|
|
752
|
+
},
|
|
753
|
+
function (reason) {
|
|
754
|
+
self.log.warning("Ping failed: " + reason);
|
|
755
|
+
self._reconnectServer();
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
}, PING_INTERVAL * 1000);
|
|
759
|
+
|
|
760
|
+
// Reset active subscriptions first
|
|
761
|
+
self._previousSubscriptions = self._activeSubscriptions;
|
|
762
|
+
self._activeSubscriptions = [];
|
|
763
|
+
|
|
764
|
+
// Resubscribe all events
|
|
765
|
+
for (let sub of self._previousSubscriptions) {
|
|
766
|
+
self.subscribe(sub.specification, sub.callback).catch(function (reason) {
|
|
767
|
+
self.log.error(`Resubscribing to ${sub.specification.event_name} failed: ${reason}`);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
resolve();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// CONNECTION CLOSE
|
|
775
|
+
self._socket.addEventListener("close", function (/*event*/) {
|
|
776
|
+
self.log.info("Disconnected from event service");
|
|
777
|
+
clearTimeout(self._pingTimerObj);
|
|
778
|
+
// We try to reconnect if possible
|
|
779
|
+
self._reconnectServer();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// CONNECTION ERROR
|
|
783
|
+
self._socket.addEventListener("error", function (error) {
|
|
784
|
+
self.log.error("Websocket connection error");
|
|
785
|
+
// Only reject promise if no connection was made (in that case promise is already resolved)
|
|
786
|
+
if (!was_connected) {
|
|
787
|
+
reject("Unable to connect: " + error);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
_reconnectServer() {
|
|
794
|
+
if (this._autoReconnect) {
|
|
795
|
+
const retryInterval = Math.floor(this._reconnectRetrySeconds);
|
|
796
|
+
this.log.info(`Reconnecting in ${retryInterval} secs`);
|
|
797
|
+
const self = this;
|
|
798
|
+
setTimeout(function () {
|
|
799
|
+
self._connectServer().then(
|
|
800
|
+
function () {
|
|
801
|
+
self.log.info("Reconnect attempt successful");
|
|
802
|
+
},
|
|
803
|
+
function () {
|
|
804
|
+
self.log.warning("Reconnect attempt failed");
|
|
805
|
+
},
|
|
806
|
+
);
|
|
807
|
+
if (self._reconnectRetrySeconds <= RETRY_MAX_BACKOFF) {
|
|
808
|
+
self._reconnectRetrySeconds = self._reconnectRetrySeconds * 2;
|
|
809
|
+
}
|
|
810
|
+
}, self._reconnectRetrySeconds * 1000);
|
|
811
|
+
} else {
|
|
812
|
+
this.log.info("Auto reconnect disabled (disconnecting?)");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
_handleIncomingEvent(request) {
|
|
817
|
+
// Find subscription by id
|
|
818
|
+
const subId = request.data.subscription_id;
|
|
819
|
+
for (let sub of this._activeSubscriptions) {
|
|
820
|
+
if (sub.id === subId) {
|
|
821
|
+
this.log.debug(`Received event for subcription id ${sub.id} payload: ` + JSON.stringify(request.data.payload));
|
|
822
|
+
try {
|
|
823
|
+
sub.callback(request.data.payload);
|
|
824
|
+
} catch (e) {
|
|
825
|
+
this.log.warning(`Callback had unhandled exception ${e}`);
|
|
826
|
+
request.resolve();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
request.resolve();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
request.reject(`Invalid subscription id for event: ${subId}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const isObject$1 = value => typeof value === 'object' && value !== null;
|
|
839
|
+
|
|
840
|
+
// Customized for this use-case
|
|
841
|
+
const isObjectCustom = value =>
|
|
842
|
+
isObject$1(value)
|
|
843
|
+
&& !(value instanceof RegExp)
|
|
844
|
+
&& !(value instanceof Error)
|
|
845
|
+
&& !(value instanceof Date)
|
|
846
|
+
&& !(globalThis.Blob && value instanceof globalThis.Blob);
|
|
847
|
+
|
|
848
|
+
const mapObjectSkip = Symbol('mapObjectSkip');
|
|
849
|
+
|
|
850
|
+
const _mapObject = (object, mapper, options, isSeen = new WeakMap()) => {
|
|
851
|
+
options = {
|
|
852
|
+
deep: false,
|
|
853
|
+
target: {},
|
|
854
|
+
...options,
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
if (isSeen.has(object)) {
|
|
858
|
+
return isSeen.get(object);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
isSeen.set(object, options.target);
|
|
862
|
+
|
|
863
|
+
const {target} = options;
|
|
864
|
+
delete options.target;
|
|
865
|
+
|
|
866
|
+
const mapArray = array => array.map(element => isObjectCustom(element) ? _mapObject(element, mapper, options, isSeen) : element);
|
|
867
|
+
if (Array.isArray(object)) {
|
|
868
|
+
return mapArray(object);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
for (const [key, value] of Object.entries(object)) {
|
|
872
|
+
const mapResult = mapper(key, value, object);
|
|
873
|
+
|
|
874
|
+
if (mapResult === mapObjectSkip) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
let [newKey, newValue, {shouldRecurse = true} = {}] = mapResult;
|
|
879
|
+
|
|
880
|
+
// Drop `__proto__` keys.
|
|
881
|
+
if (newKey === '__proto__') {
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (options.deep && shouldRecurse && isObjectCustom(newValue)) {
|
|
886
|
+
newValue = Array.isArray(newValue)
|
|
887
|
+
? mapArray(newValue)
|
|
888
|
+
: _mapObject(newValue, mapper, options, isSeen);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
target[newKey] = newValue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return target;
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
function mapObject(object, mapper, options) {
|
|
898
|
+
if (!isObject$1(object)) {
|
|
899
|
+
throw new TypeError(`Expected an object, got \`${object}\` (${typeof object})`);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (Array.isArray(object)) {
|
|
903
|
+
throw new TypeError('Expected an object, got an array');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return _mapObject(object, mapper, options);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const UPPERCASE = /[\p{Lu}]/u;
|
|
910
|
+
const LOWERCASE = /[\p{Ll}]/u;
|
|
911
|
+
const LEADING_CAPITAL = /^[\p{Lu}](?![\p{Lu}])/gu;
|
|
912
|
+
const IDENTIFIER = /([\p{Alpha}\p{N}_]|$)/u;
|
|
913
|
+
const SEPARATORS = /[_.\- ]+/;
|
|
914
|
+
|
|
915
|
+
const LEADING_SEPARATORS = new RegExp('^' + SEPARATORS.source);
|
|
916
|
+
const SEPARATORS_AND_IDENTIFIER = new RegExp(SEPARATORS.source + IDENTIFIER.source, 'gu');
|
|
917
|
+
const NUMBERS_AND_IDENTIFIER = new RegExp('\\d+' + IDENTIFIER.source, 'gu');
|
|
918
|
+
|
|
919
|
+
const preserveCamelCase = (string, toLowerCase, toUpperCase, preserveConsecutiveUppercase) => {
|
|
920
|
+
let isLastCharLower = false;
|
|
921
|
+
let isLastCharUpper = false;
|
|
922
|
+
let isLastLastCharUpper = false;
|
|
923
|
+
let isLastLastCharPreserved = false;
|
|
924
|
+
|
|
925
|
+
for (let index = 0; index < string.length; index++) {
|
|
926
|
+
const character = string[index];
|
|
927
|
+
isLastLastCharPreserved = index > 2 ? string[index - 3] === '-' : true;
|
|
928
|
+
|
|
929
|
+
if (isLastCharLower && UPPERCASE.test(character)) {
|
|
930
|
+
string = string.slice(0, index) + '-' + string.slice(index);
|
|
931
|
+
isLastCharLower = false;
|
|
932
|
+
isLastLastCharUpper = isLastCharUpper;
|
|
933
|
+
isLastCharUpper = true;
|
|
934
|
+
index++;
|
|
935
|
+
} else if (isLastCharUpper && isLastLastCharUpper && LOWERCASE.test(character) && (!isLastLastCharPreserved || preserveConsecutiveUppercase)) {
|
|
936
|
+
string = string.slice(0, index - 1) + '-' + string.slice(index - 1);
|
|
937
|
+
isLastLastCharUpper = isLastCharUpper;
|
|
938
|
+
isLastCharUpper = false;
|
|
939
|
+
isLastCharLower = true;
|
|
940
|
+
} else {
|
|
941
|
+
isLastCharLower = toLowerCase(character) === character && toUpperCase(character) !== character;
|
|
942
|
+
isLastLastCharUpper = isLastCharUpper;
|
|
943
|
+
isLastCharUpper = toUpperCase(character) === character && toLowerCase(character) !== character;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return string;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const preserveConsecutiveUppercase = (input, toLowerCase) => {
|
|
951
|
+
LEADING_CAPITAL.lastIndex = 0;
|
|
952
|
+
|
|
953
|
+
return input.replaceAll(LEADING_CAPITAL, match => toLowerCase(match));
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const postProcess = (input, toUpperCase) => {
|
|
957
|
+
SEPARATORS_AND_IDENTIFIER.lastIndex = 0;
|
|
958
|
+
NUMBERS_AND_IDENTIFIER.lastIndex = 0;
|
|
959
|
+
|
|
960
|
+
return input
|
|
961
|
+
.replaceAll(NUMBERS_AND_IDENTIFIER, (match, pattern, offset) => ['_', '-'].includes(input.charAt(offset + match.length)) ? match : toUpperCase(match))
|
|
962
|
+
.replaceAll(SEPARATORS_AND_IDENTIFIER, (_, identifier) => toUpperCase(identifier));
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
function camelCase(input, options) {
|
|
966
|
+
if (!(typeof input === 'string' || Array.isArray(input))) {
|
|
967
|
+
throw new TypeError('Expected the input to be `string | string[]`');
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
options = {
|
|
971
|
+
pascalCase: false,
|
|
972
|
+
preserveConsecutiveUppercase: false,
|
|
973
|
+
...options,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
if (Array.isArray(input)) {
|
|
977
|
+
input = input.map(x => x.trim())
|
|
978
|
+
.filter(x => x.length)
|
|
979
|
+
.join('-');
|
|
980
|
+
} else {
|
|
981
|
+
input = input.trim();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (input.length === 0) {
|
|
985
|
+
return '';
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const toLowerCase = options.locale === false
|
|
989
|
+
? string => string.toLowerCase()
|
|
990
|
+
: string => string.toLocaleLowerCase(options.locale);
|
|
991
|
+
|
|
992
|
+
const toUpperCase = options.locale === false
|
|
993
|
+
? string => string.toUpperCase()
|
|
994
|
+
: string => string.toLocaleUpperCase(options.locale);
|
|
995
|
+
|
|
996
|
+
if (input.length === 1) {
|
|
997
|
+
if (SEPARATORS.test(input)) {
|
|
998
|
+
return '';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return options.pascalCase ? toUpperCase(input) : toLowerCase(input);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const hasUpperCase = input !== toLowerCase(input);
|
|
1005
|
+
|
|
1006
|
+
if (hasUpperCase) {
|
|
1007
|
+
input = preserveCamelCase(input, toLowerCase, toUpperCase, options.preserveConsecutiveUppercase);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
input = input.replace(LEADING_SEPARATORS, '');
|
|
1011
|
+
input = options.preserveConsecutiveUppercase ? preserveConsecutiveUppercase(input, toLowerCase) : toLowerCase(input);
|
|
1012
|
+
|
|
1013
|
+
if (options.pascalCase) {
|
|
1014
|
+
input = toUpperCase(input.charAt(0)) + input.slice(1);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return postProcess(input, toUpperCase);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
class QuickLRU extends Map {
|
|
1021
|
+
#size = 0;
|
|
1022
|
+
#cache = new Map();
|
|
1023
|
+
#oldCache = new Map();
|
|
1024
|
+
#maxSize;
|
|
1025
|
+
#maxAge;
|
|
1026
|
+
#onEviction;
|
|
1027
|
+
|
|
1028
|
+
constructor(options = {}) {
|
|
1029
|
+
super();
|
|
1030
|
+
|
|
1031
|
+
if (!(options.maxSize && options.maxSize > 0)) {
|
|
1032
|
+
throw new TypeError('`maxSize` must be a number greater than 0');
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (typeof options.maxAge === 'number' && options.maxAge === 0) {
|
|
1036
|
+
throw new TypeError('`maxAge` must be a number greater than 0');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
this.#maxSize = options.maxSize;
|
|
1040
|
+
this.#maxAge = options.maxAge || Number.POSITIVE_INFINITY;
|
|
1041
|
+
this.#onEviction = options.onEviction;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// For tests.
|
|
1045
|
+
get __oldCache() {
|
|
1046
|
+
return this.#oldCache;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
#emitEvictions(cache) {
|
|
1050
|
+
if (typeof this.#onEviction !== 'function') {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
for (const [key, item] of cache) {
|
|
1055
|
+
this.#onEviction(key, item.value);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
#deleteIfExpired(key, item) {
|
|
1060
|
+
if (typeof item.expiry === 'number' && item.expiry <= Date.now()) {
|
|
1061
|
+
if (typeof this.#onEviction === 'function') {
|
|
1062
|
+
this.#onEviction(key, item.value);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return this.delete(key);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#getOrDeleteIfExpired(key, item) {
|
|
1072
|
+
const deleted = this.#deleteIfExpired(key, item);
|
|
1073
|
+
if (deleted === false) {
|
|
1074
|
+
return item.value;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
#getItemValue(key, item) {
|
|
1079
|
+
return item.expiry ? this.#getOrDeleteIfExpired(key, item) : item.value;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
#peek(key, cache) {
|
|
1083
|
+
const item = cache.get(key);
|
|
1084
|
+
return this.#getItemValue(key, item);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
#set(key, value) {
|
|
1088
|
+
this.#cache.set(key, value);
|
|
1089
|
+
this.#size++;
|
|
1090
|
+
|
|
1091
|
+
if (this.#size >= this.#maxSize) {
|
|
1092
|
+
this.#size = 0;
|
|
1093
|
+
this.#emitEvictions(this.#oldCache);
|
|
1094
|
+
this.#oldCache = this.#cache;
|
|
1095
|
+
this.#cache = new Map();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
#moveToRecent(key, item) {
|
|
1100
|
+
this.#oldCache.delete(key);
|
|
1101
|
+
this.#set(key, item);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
* #entriesAscending() {
|
|
1105
|
+
for (const item of this.#oldCache) {
|
|
1106
|
+
const [key, value] = item;
|
|
1107
|
+
if (!this.#cache.has(key)) {
|
|
1108
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1109
|
+
if (deleted === false) {
|
|
1110
|
+
yield item;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
for (const item of this.#cache) {
|
|
1116
|
+
const [key, value] = item;
|
|
1117
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1118
|
+
if (deleted === false) {
|
|
1119
|
+
yield item;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
get(key) {
|
|
1125
|
+
if (this.#cache.has(key)) {
|
|
1126
|
+
const item = this.#cache.get(key);
|
|
1127
|
+
return this.#getItemValue(key, item);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (this.#oldCache.has(key)) {
|
|
1131
|
+
const item = this.#oldCache.get(key);
|
|
1132
|
+
if (this.#deleteIfExpired(key, item) === false) {
|
|
1133
|
+
this.#moveToRecent(key, item);
|
|
1134
|
+
return item.value;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
set(key, value, {maxAge = this.#maxAge} = {}) {
|
|
1140
|
+
const expiry = typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY
|
|
1141
|
+
? (Date.now() + maxAge)
|
|
1142
|
+
: undefined;
|
|
1143
|
+
|
|
1144
|
+
if (this.#cache.has(key)) {
|
|
1145
|
+
this.#cache.set(key, {
|
|
1146
|
+
value,
|
|
1147
|
+
expiry,
|
|
1148
|
+
});
|
|
1149
|
+
} else {
|
|
1150
|
+
this.#set(key, {value, expiry});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return this;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
has(key) {
|
|
1157
|
+
if (this.#cache.has(key)) {
|
|
1158
|
+
return !this.#deleteIfExpired(key, this.#cache.get(key));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (this.#oldCache.has(key)) {
|
|
1162
|
+
return !this.#deleteIfExpired(key, this.#oldCache.get(key));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
peek(key) {
|
|
1169
|
+
if (this.#cache.has(key)) {
|
|
1170
|
+
return this.#peek(key, this.#cache);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (this.#oldCache.has(key)) {
|
|
1174
|
+
return this.#peek(key, this.#oldCache);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
expiresIn(key) {
|
|
1179
|
+
const item = this.#cache.get(key) ?? this.#oldCache.get(key);
|
|
1180
|
+
if (item) {
|
|
1181
|
+
return item.expiry ? item.expiry - Date.now() : Number.POSITIVE_INFINITY;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
delete(key) {
|
|
1186
|
+
const deleted = this.#cache.delete(key);
|
|
1187
|
+
if (deleted) {
|
|
1188
|
+
this.#size--;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return this.#oldCache.delete(key) || deleted;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
clear() {
|
|
1195
|
+
this.#cache.clear();
|
|
1196
|
+
this.#oldCache.clear();
|
|
1197
|
+
this.#size = 0;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
resize(newSize) {
|
|
1201
|
+
if (!(newSize && newSize > 0)) {
|
|
1202
|
+
throw new TypeError('`maxSize` must be a number greater than 0');
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const items = [...this.#entriesAscending()];
|
|
1206
|
+
const removeCount = items.length - newSize;
|
|
1207
|
+
if (removeCount < 0) {
|
|
1208
|
+
this.#cache = new Map(items);
|
|
1209
|
+
this.#oldCache = new Map();
|
|
1210
|
+
this.#size = items.length;
|
|
1211
|
+
} else {
|
|
1212
|
+
if (removeCount > 0) {
|
|
1213
|
+
this.#emitEvictions(items.slice(0, removeCount));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
this.#oldCache = new Map(items.slice(removeCount));
|
|
1217
|
+
this.#cache = new Map();
|
|
1218
|
+
this.#size = 0;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
this.#maxSize = newSize;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
evict(count = 1) {
|
|
1225
|
+
const requested = Number(count);
|
|
1226
|
+
if (!requested || requested <= 0) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const items = [...this.#entriesAscending()];
|
|
1231
|
+
const evictCount = Math.trunc(Math.min(requested, Math.max(items.length - 1, 0)));
|
|
1232
|
+
if (evictCount <= 0) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
this.#emitEvictions(items.slice(0, evictCount));
|
|
1237
|
+
this.#oldCache = new Map(items.slice(evictCount));
|
|
1238
|
+
this.#cache = new Map();
|
|
1239
|
+
this.#size = 0;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
* keys() {
|
|
1243
|
+
for (const [key] of this) {
|
|
1244
|
+
yield key;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
* values() {
|
|
1249
|
+
for (const [, value] of this) {
|
|
1250
|
+
yield value;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
* [Symbol.iterator]() {
|
|
1255
|
+
for (const item of this.#cache) {
|
|
1256
|
+
const [key, value] = item;
|
|
1257
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1258
|
+
if (deleted === false) {
|
|
1259
|
+
yield [key, value.value];
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
for (const item of this.#oldCache) {
|
|
1264
|
+
const [key, value] = item;
|
|
1265
|
+
if (!this.#cache.has(key)) {
|
|
1266
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1267
|
+
if (deleted === false) {
|
|
1268
|
+
yield [key, value.value];
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
* entriesDescending() {
|
|
1275
|
+
let items = [...this.#cache];
|
|
1276
|
+
for (let i = items.length - 1; i >= 0; --i) {
|
|
1277
|
+
const item = items[i];
|
|
1278
|
+
const [key, value] = item;
|
|
1279
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1280
|
+
if (deleted === false) {
|
|
1281
|
+
yield [key, value.value];
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
items = [...this.#oldCache];
|
|
1286
|
+
for (let i = items.length - 1; i >= 0; --i) {
|
|
1287
|
+
const item = items[i];
|
|
1288
|
+
const [key, value] = item;
|
|
1289
|
+
if (!this.#cache.has(key)) {
|
|
1290
|
+
const deleted = this.#deleteIfExpired(key, value);
|
|
1291
|
+
if (deleted === false) {
|
|
1292
|
+
yield [key, value.value];
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
* entriesAscending() {
|
|
1299
|
+
for (const [key, value] of this.#entriesAscending()) {
|
|
1300
|
+
yield [key, value.value];
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
get size() {
|
|
1305
|
+
if (!this.#size) {
|
|
1306
|
+
return this.#oldCache.size;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
let oldCacheSize = 0;
|
|
1310
|
+
for (const key of this.#oldCache.keys()) {
|
|
1311
|
+
if (!this.#cache.has(key)) {
|
|
1312
|
+
oldCacheSize++;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return Math.min(this.#size + oldCacheSize, this.#maxSize);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
get maxSize() {
|
|
1320
|
+
return this.#maxSize;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
get maxAge() {
|
|
1324
|
+
return this.#maxAge;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
entries() {
|
|
1328
|
+
return this.entriesAscending();
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
forEach(callbackFunction, thisArgument = this) {
|
|
1332
|
+
for (const [key, value] of this.entriesAscending()) {
|
|
1333
|
+
callbackFunction.call(thisArgument, value, key, this);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
get [Symbol.toStringTag]() {
|
|
1338
|
+
return 'QuickLRU';
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
toString() {
|
|
1342
|
+
return `QuickLRU(${this.size}/${this.maxSize})`;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
1346
|
+
return this.toString();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const has = (array, key) => array.some(element => {
|
|
1351
|
+
if (typeof element === 'string') {
|
|
1352
|
+
return element === key;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
element.lastIndex = 0;
|
|
1356
|
+
|
|
1357
|
+
return element.test(key);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
// Preserve numeric keys. The trim check excludes empty/whitespace strings,
|
|
1361
|
+
// which Number() would incorrectly convert to 0.
|
|
1362
|
+
const isNumericKey = key => key.trim() !== '' && !Number.isNaN(Number(key));
|
|
1363
|
+
|
|
1364
|
+
const cache = new QuickLRU({maxSize: 100_000});
|
|
1365
|
+
|
|
1366
|
+
const isBuiltIn = value =>
|
|
1367
|
+
ArrayBuffer.isView(value)
|
|
1368
|
+
// Check for known built-in types
|
|
1369
|
+
|| value instanceof Date
|
|
1370
|
+
|| value instanceof RegExp
|
|
1371
|
+
|| value instanceof Error
|
|
1372
|
+
|| value instanceof Map
|
|
1373
|
+
|| value instanceof Set
|
|
1374
|
+
|| value instanceof WeakMap
|
|
1375
|
+
|| value instanceof WeakSet
|
|
1376
|
+
|| value instanceof Promise;
|
|
1377
|
+
|
|
1378
|
+
// Reproduces behavior from `map-obj`.
|
|
1379
|
+
const isObject = value =>
|
|
1380
|
+
typeof value === 'object'
|
|
1381
|
+
&& value !== null
|
|
1382
|
+
&& !isBuiltIn(value);
|
|
1383
|
+
|
|
1384
|
+
const transform = (input, options = {}, isSeen = new WeakMap(), parentPath) => {
|
|
1385
|
+
if (!isObject(input)) {
|
|
1386
|
+
return input;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check for circular references
|
|
1390
|
+
if (isSeen.has(input)) {
|
|
1391
|
+
return isSeen.get(input);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const {
|
|
1395
|
+
exclude,
|
|
1396
|
+
pascalCase = false,
|
|
1397
|
+
stopPaths,
|
|
1398
|
+
deep = false,
|
|
1399
|
+
preserveConsecutiveUppercase = false,
|
|
1400
|
+
} = options;
|
|
1401
|
+
|
|
1402
|
+
const stopPathsSet = new Set(stopPaths);
|
|
1403
|
+
|
|
1404
|
+
// Handle arrays directly
|
|
1405
|
+
if (Array.isArray(input)) {
|
|
1406
|
+
const result = [];
|
|
1407
|
+
isSeen.set(input, result);
|
|
1408
|
+
|
|
1409
|
+
for (const item of input) {
|
|
1410
|
+
result.push(isObject(item) ? transform(item, options, isSeen, parentPath) : item);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return result;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Pre-allocate the result object for circular reference handling
|
|
1417
|
+
const result = {};
|
|
1418
|
+
isSeen.set(input, result);
|
|
1419
|
+
|
|
1420
|
+
const makeMapper = currentParentPath => (key, value) => {
|
|
1421
|
+
// Handle deep transformation
|
|
1422
|
+
if (deep && isObject(value)) {
|
|
1423
|
+
const path = currentParentPath === undefined ? key : `${currentParentPath}.${key}`;
|
|
1424
|
+
|
|
1425
|
+
if (!stopPathsSet.has(path)) {
|
|
1426
|
+
// Handle arrays and objects recursively
|
|
1427
|
+
value = Array.isArray(value)
|
|
1428
|
+
? value.map(item => isObject(item) ? transform(item, options, isSeen, path) : item)
|
|
1429
|
+
: transform(value, options, isSeen, path);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Skip transformation for excluded keys, numeric keys
|
|
1434
|
+
// Only transform string keys (preserve symbols and numbers)
|
|
1435
|
+
if (typeof key === 'string' && !isNumericKey(key) && !(exclude && has(exclude, key))) {
|
|
1436
|
+
const cacheKey = pascalCase ? `${key}_` : key;
|
|
1437
|
+
|
|
1438
|
+
if (cache.has(cacheKey)) {
|
|
1439
|
+
key = cache.get(cacheKey);
|
|
1440
|
+
} else {
|
|
1441
|
+
// Preserve leading `_` and `$` as they typically have semantic meaning
|
|
1442
|
+
// This should eventually be fixed in the `camelcase` package itself
|
|
1443
|
+
const leadingPrefix = key.match(/^[_$]*/)[0];
|
|
1444
|
+
const transformed = camelCase(key.slice(leadingPrefix.length), {pascalCase, locale: false, preserveConsecutiveUppercase});
|
|
1445
|
+
key = leadingPrefix + transformed;
|
|
1446
|
+
|
|
1447
|
+
// Only cache reasonable length keys to prevent memory abuse
|
|
1448
|
+
if (key.length < 100) {
|
|
1449
|
+
cache.set(cacheKey, key);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return [key, value];
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
const mappedResult = mapObject(input, makeMapper(parentPath), {deep: false});
|
|
1458
|
+
|
|
1459
|
+
// Copy properties to the pre-allocated result for circular reference handling
|
|
1460
|
+
Object.assign(result, mappedResult);
|
|
1461
|
+
|
|
1462
|
+
// Preserve symbol keys (mapObject doesn't handle them)
|
|
1463
|
+
const symbols = Object.getOwnPropertySymbols(input);
|
|
1464
|
+
for (const symbol of symbols) {
|
|
1465
|
+
result[symbol] = deep && isObject(input[symbol])
|
|
1466
|
+
? transform(input[symbol], options, isSeen, parentPath)
|
|
1467
|
+
: input[symbol];
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
return result;
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
function camelcaseKeys(input, options) {
|
|
1474
|
+
const isSeen = new WeakMap();
|
|
1475
|
+
|
|
1476
|
+
if (Array.isArray(input)) {
|
|
1477
|
+
// More efficient array handling - directly map the array
|
|
1478
|
+
return input.map(item =>
|
|
1479
|
+
isObject(item)
|
|
1480
|
+
? transform(item, options, isSeen, undefined)
|
|
1481
|
+
: item);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return transform(input, options, isSeen);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// 'use strict';
|
|
1488
|
+
|
|
1489
|
+
const options = {
|
|
1490
|
+
wssServer: "wss://api.telegenta.com/sip/",
|
|
1491
|
+
eventServer: "wss://api.telegenta.com/es/",
|
|
1492
|
+
provisionToken: null,
|
|
1493
|
+
authorizationUsername: null,
|
|
1494
|
+
password: null,
|
|
1495
|
+
callToken: null,
|
|
1496
|
+
register: true,
|
|
1497
|
+
iceGatherTimeout: 3000,
|
|
1498
|
+
playSignalTones: true,
|
|
1499
|
+
logger: null,
|
|
1500
|
+
logLevel: 3,
|
|
1501
|
+
connectEventService: false,
|
|
1502
|
+
useCallDataInfoEvent: false,
|
|
1503
|
+
toneVolume: 0.3,
|
|
1504
|
+
autoBusyIncoming: true,
|
|
1505
|
+
autoRemoveSessionTime: 3000,
|
|
1506
|
+
playRingOnIncoming: true,
|
|
1507
|
+
playCallInitializedTone: false,
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// DTMF Frequency table
|
|
1511
|
+
const DTMF_FREQUENCY_TABLE = {
|
|
1512
|
+
1: [1209, 697],
|
|
1513
|
+
2: [1336, 697],
|
|
1514
|
+
3: [1477, 697],
|
|
1515
|
+
a: [1633, 697],
|
|
1516
|
+
4: [1209, 770],
|
|
1517
|
+
5: [1336, 770],
|
|
1518
|
+
6: [1477, 770],
|
|
1519
|
+
b: [1633, 770],
|
|
1520
|
+
7: [1209, 852],
|
|
1521
|
+
8: [1336, 852],
|
|
1522
|
+
9: [1477, 852],
|
|
1523
|
+
c: [1633, 852],
|
|
1524
|
+
"*": [1209, 941],
|
|
1525
|
+
0: [1336, 941],
|
|
1526
|
+
"#": [1477, 941],
|
|
1527
|
+
d: [1633, 941],
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
// SIGNAL TONE SEQUENCES
|
|
1531
|
+
|
|
1532
|
+
// Params: d = Duration, f1 = Frequency 1, f2 = Frequency 2, g = Gap
|
|
1533
|
+
const ST_TEMP_UNAVAIL = [{ d: 250, f1: 425, f2: 0, g: 250 }];
|
|
1534
|
+
const ST_BUSY = [{ d: 500, f1: 425, f2: 0, g: 500 }];
|
|
1535
|
+
const ST_INVALID_NUMBER = [
|
|
1536
|
+
{ d: 333, f1: 950, f2: 0, g: 0 },
|
|
1537
|
+
{ d: 333, f1: 1400, f2: 0, g: 0 },
|
|
1538
|
+
{ d: 333, f1: 1800, f2: 0, g: 1000 },
|
|
1539
|
+
];
|
|
1540
|
+
const ST_CALLINIT = [
|
|
1541
|
+
{ d: 200, f1: 500, f2: 0, g: 0 },
|
|
1542
|
+
{ d: 200, f1: 700, f2: 0, g: 5000 },
|
|
1543
|
+
];
|
|
1544
|
+
const ST_RINGBACK = [{ d: 1000, f1: 425, f2: 0, g: 4000 }];
|
|
1545
|
+
const ST_INCOMING = [
|
|
1546
|
+
{ d: 150, f1: 400, f2: 0, g: 0 },
|
|
1547
|
+
{ d: 150, f1: 600, f2: 0, g: 0 },
|
|
1548
|
+
{ d: 150, f1: 400, f2: 0, g: 0 },
|
|
1549
|
+
{ d: 150, f1: 600, f2: 0, g: 0 },
|
|
1550
|
+
{ d: 150, f1: 400, f2: 0, g: 0 },
|
|
1551
|
+
{ d: 150, f1: 600, f2: 0, g: 3000 },
|
|
1552
|
+
];
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* This class acts as a SIP user Agent and is responsible for handling calls using the WebRTC interface in the browser.<br>
|
|
1556
|
+
* If you have your own log handler or if you want to adjust the log level you can create your own logging instance, when you create the Phone
|
|
1557
|
+
* instance. See {@link LogHandler} class for more information.
|
|
1558
|
+
*
|
|
1559
|
+
* @param {LogHandler} [logger] - Custom log instance
|
|
1560
|
+
*
|
|
1561
|
+
* @fires connectionStateChange on connection state changes.
|
|
1562
|
+
* @fires sessionCreated When a new session has been created
|
|
1563
|
+
* @fires sessionRemoved When a existing session has been removed (after autoRemoveSessionTime)
|
|
1564
|
+
* @fires callDataInfo Receive detailed information about call after call has been terminated
|
|
1565
|
+
* @fires incomingCall Incoming call sessions are received on this event
|
|
1566
|
+
*
|
|
1567
|
+
* @example
|
|
1568
|
+
* // Creating Phone object and use it make a call
|
|
1569
|
+
* const phone = new Telegenta.Phone()
|
|
1570
|
+
* phone.prepareMedia().then(() => {
|
|
1571
|
+
* // Hook up events
|
|
1572
|
+
* ...
|
|
1573
|
+
* // Start phone
|
|
1574
|
+
* phone.start(options);
|
|
1575
|
+
*
|
|
1576
|
+
* // Dial 12345678
|
|
1577
|
+
* session = phone.call('12345678');
|
|
1578
|
+
* }
|
|
1579
|
+
*/
|
|
1580
|
+
class Phone extends EventEmitter {
|
|
1581
|
+
constructor(logger = null) {
|
|
1582
|
+
super();
|
|
1583
|
+
|
|
1584
|
+
this._mediaPermission = false;
|
|
1585
|
+
this._mediaPermissionError = false;
|
|
1586
|
+
this._UA = null;
|
|
1587
|
+
this._connectionState = null;
|
|
1588
|
+
this._isStarted = false;
|
|
1589
|
+
this._eventClient = null;
|
|
1590
|
+
this._options = null;
|
|
1591
|
+
|
|
1592
|
+
// List of active sessions
|
|
1593
|
+
this._sessionCounter = 0;
|
|
1594
|
+
this._activeSessions = {};
|
|
1595
|
+
|
|
1596
|
+
// Create remote audio element
|
|
1597
|
+
this._remoteAudioElement = document.createElement("AUDIO");
|
|
1598
|
+
this._remoteAudioElement.autoplay = true;
|
|
1599
|
+
|
|
1600
|
+
// Tone generator
|
|
1601
|
+
this._toneAudioElement = document.createElement("AUDIO");
|
|
1602
|
+
this._toneAudioElement.autoplay = true;
|
|
1603
|
+
this._toneTimer = null;
|
|
1604
|
+
this._repeatToneActive = false;
|
|
1605
|
+
this._repeatToneSequence = [];
|
|
1606
|
+
this._repeatToneSequencePos = 0;
|
|
1607
|
+
this._toneGainProcessor = null;
|
|
1608
|
+
this._oscillator1 = null;
|
|
1609
|
+
this._oscillator2 = null;
|
|
1610
|
+
|
|
1611
|
+
// Set default logger
|
|
1612
|
+
this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
|
|
1613
|
+
|
|
1614
|
+
// Handle jssip debug logs
|
|
1615
|
+
if (this.log.level === LOGLVL$1.DEBUG) {
|
|
1616
|
+
JsSIP.debug.disable("JsSIP:*");
|
|
1617
|
+
} else {
|
|
1618
|
+
JsSIP.debug.disable("JsSIP:*");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Prepare media and get media permission from browser
|
|
1624
|
+
* This must be done prior to using the phone or getting device lists.
|
|
1625
|
+
*
|
|
1626
|
+
* @return A promise which will be resolved when user accepts access to media devices
|
|
1627
|
+
*
|
|
1628
|
+
*/
|
|
1629
|
+
prepareMedia() {
|
|
1630
|
+
this.log.info("Get media permission");
|
|
1631
|
+
const self = this;
|
|
1632
|
+
const constraints = { audio: true, video: false };
|
|
1633
|
+
return new Promise(function (resolve, reject) {
|
|
1634
|
+
navigator.mediaDevices.getUserMedia(constraints).then(
|
|
1635
|
+
() => {
|
|
1636
|
+
self._mediaPermission = true;
|
|
1637
|
+
self._mediaPermissionError = false;
|
|
1638
|
+
self.log.info("Media permission granted");
|
|
1639
|
+
resolve();
|
|
1640
|
+
},
|
|
1641
|
+
(err) => {
|
|
1642
|
+
self._mediaPermission = false;
|
|
1643
|
+
self._mediaPermissionError = true;
|
|
1644
|
+
self.log.error("Denied media permission: " + err);
|
|
1645
|
+
reject(err);
|
|
1646
|
+
},
|
|
1647
|
+
);
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Start phone and connect to server if needed
|
|
1653
|
+
*
|
|
1654
|
+
* @param phoneOptions {object} configuration options
|
|
1655
|
+
* @param {string} [phoneOptions.provisionToken] Provisioning token from API service
|
|
1656
|
+
* @param {string} [phoneOptions.authorizationUsername] Authorizing username
|
|
1657
|
+
* @param {string} [phoneOptions.password] WSS Password
|
|
1658
|
+
* @param {string} [phoneOptions.callToken] Call token
|
|
1659
|
+
* @param {boolean} [phoneOptions.connectEventService=false] Connect to event service (required to send call commands and receive callDataInfo event)
|
|
1660
|
+
* @param {boolean} [phoneOptions.useCallDataInfoEvent=false] Subscribe to callDataInfo remote event
|
|
1661
|
+
* @param {boolean} [phoneOptions.register=true] Register on server to receive enable incoming calls
|
|
1662
|
+
* @param {number} [phoneOptions.playSignalTones=false] Play signal tones (busy, invalid number, etc) remotely
|
|
1663
|
+
* @param {number} [phoneOptions.iceGatherTimeout=3000] Number of millisecs before ICE gather will be forced stopped and candidate selection will take place
|
|
1664
|
+
* @param {number} [phoneOptions.toneVolume=0.3] Signal- and DTMF tone volume (0-1)
|
|
1665
|
+
* @param {boolean} [phoneOptions.autoBusyIncoming=true] Automatically reject calls with busy if active session exists
|
|
1666
|
+
* @param {number} [phoneOptions.autoRemoveSessionTime=3000] Automatically remove session after interval specified
|
|
1667
|
+
* @param {number} [phoneOptions.playRingOnIncoming=true] Play ringing tones on incoming calls
|
|
1668
|
+
* @param {number} [phoneOptions.playCallInitializedTone=false] Play tone when call has been initiated (before progress/ringback tones)
|
|
1669
|
+
*
|
|
1670
|
+
* @throws MediaNotPrepared - If {@link prepareMedia} was not called and resolved prior to calling this method
|
|
1671
|
+
* @throws InvalidOptions - If one or more options are invalid
|
|
1672
|
+
* @throws UnableToConnect - If unable to connect phone to server
|
|
1673
|
+
*/
|
|
1674
|
+
|
|
1675
|
+
start(phoneOptions) {
|
|
1676
|
+
this._checkMediaPermission();
|
|
1677
|
+
// Check phone is not running already, if so stop it first
|
|
1678
|
+
if (this._isStarted) {
|
|
1679
|
+
this.log.warning("Phone already started, stopping first");
|
|
1680
|
+
this.stop();
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Apply options
|
|
1684
|
+
this._options = Object.assign(options, phoneOptions);
|
|
1685
|
+
|
|
1686
|
+
this._initToneGenerator();
|
|
1687
|
+
|
|
1688
|
+
// Unpack provisioning token if specified
|
|
1689
|
+
if (this._options.provisionToken) {
|
|
1690
|
+
let pt = null;
|
|
1691
|
+
try {
|
|
1692
|
+
pt = JSON.parse(atob(this._options.provisionToken));
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
throw new InvalidOptions("provisionToken", "Invalid token");
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
this.log.info("Using provisioning token authorization");
|
|
1698
|
+
this._options.wssServer = pt.wss_server;
|
|
1699
|
+
this._options.eventServer = pt.event_server;
|
|
1700
|
+
this._options.authorizationUsername = pt.wss_username;
|
|
1701
|
+
this._options.password = pt.wss_password;
|
|
1702
|
+
this._options.callToken = pt.wss_token;
|
|
1703
|
+
} else {
|
|
1704
|
+
this.log.info("Using manual specified credentials");
|
|
1705
|
+
|
|
1706
|
+
// Check auth info is provided
|
|
1707
|
+
if (!this._options.authorizationUsername) {
|
|
1708
|
+
throw new InvalidOptions("authorizationUsername", "Missing username");
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
if (!this._options.password) {
|
|
1712
|
+
throw new InvalidOptions("password", "Missing password");
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (!this._options.callToken) {
|
|
1716
|
+
throw new InvalidOptions("callToken", "Missing call token");
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// If we must subscribe to call data info event, we enable event service
|
|
1721
|
+
if (this._options.useCallDataInfoEvent && !this._options.connectEventService) {
|
|
1722
|
+
this.log.info(`Enabled event service because 'useCallDataInfoEvent' is enabled`);
|
|
1723
|
+
this._options.connectEventService = true;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// Connect to event service if needed
|
|
1727
|
+
if (this._options.connectEventService) {
|
|
1728
|
+
// Create and connect event client
|
|
1729
|
+
this._eventClient = new EventClient(this.log);
|
|
1730
|
+
|
|
1731
|
+
this._eventClient.connect(this._options.eventServer).then(
|
|
1732
|
+
() => {
|
|
1733
|
+
this.log.info(`Connected to event server`);
|
|
1734
|
+
|
|
1735
|
+
// Subscript to CallDataEvent is needed
|
|
1736
|
+
if (this._options.useCallDataInfoEvent) {
|
|
1737
|
+
// Subscribe to calldata_available events
|
|
1738
|
+
let eventSpec = {
|
|
1739
|
+
event_name: "calldata_available",
|
|
1740
|
+
call_token: this._options.callToken,
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
this._eventClient
|
|
1744
|
+
.subscribe(eventSpec, (data) => {
|
|
1745
|
+
try {
|
|
1746
|
+
this.log.debug("Received remote call data info");
|
|
1747
|
+
this.log.debug(data);
|
|
1748
|
+
this._fireCallDataInfoEvent(data);
|
|
1749
|
+
} catch (e) {
|
|
1750
|
+
this.log.error("Event callback failed: " + e);
|
|
1751
|
+
}
|
|
1752
|
+
})
|
|
1753
|
+
.then(
|
|
1754
|
+
(subscriptionId) => {
|
|
1755
|
+
this.log.info(`Subscription successful ID = ${subscriptionId}`);
|
|
1756
|
+
},
|
|
1757
|
+
(reason) => {
|
|
1758
|
+
this.log.error(`Subscribe failed: ${reason}`);
|
|
1759
|
+
},
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
},
|
|
1763
|
+
(reason) => {
|
|
1764
|
+
this.log.error(`Unable to connect to event server: ${reason}`);
|
|
1765
|
+
},
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
let wsSocket = null;
|
|
1770
|
+
try {
|
|
1771
|
+
wsSocket = new JsSIP.WebSocketInterface(this._options.wssServer);
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
throw new UnableToConnect("Unable to connect phone websocket: " + err);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
this.log.info("Starting phone");
|
|
1777
|
+
this.log.debug(this._options);
|
|
1778
|
+
|
|
1779
|
+
this._UA = new JsSIP.UA({
|
|
1780
|
+
sockets: [wsSocket],
|
|
1781
|
+
authorization_user: this._options.authorizationUsername,
|
|
1782
|
+
password: this._options.password,
|
|
1783
|
+
register: this._options.register,
|
|
1784
|
+
display_name: this._options.authorizationUsername,
|
|
1785
|
+
uri: this._options.authorizationUsername + "@telegenta.com",
|
|
1786
|
+
session_timers: false,
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
// Setup remote media handler
|
|
1790
|
+
const self = this;
|
|
1791
|
+
this._UA.on("newRTCSession", function (data) {
|
|
1792
|
+
self.log.debug("New RTCSession");
|
|
1793
|
+
if (data.originator === "remote") {
|
|
1794
|
+
// Create new session
|
|
1795
|
+
const session = new Session(self);
|
|
1796
|
+
|
|
1797
|
+
session.newInboundSession(data);
|
|
1798
|
+
|
|
1799
|
+
if (self._options.playRingOnIncoming) {
|
|
1800
|
+
self._startRepeatTone(ST_INCOMING);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Check if we have other sessions and reject call if autoBusyIncoming is true
|
|
1804
|
+
if (self._options.autoBusyIncoming) {
|
|
1805
|
+
if (Object.keys(self._activeSessions).length > 0) {
|
|
1806
|
+
self.log.info(`Autorejecting incoming call ${session.uuid} because there is already active calls`);
|
|
1807
|
+
session.reject(486, "Busy");
|
|
1808
|
+
return;
|
|
1809
|
+
} else {
|
|
1810
|
+
self.log.debug("There are no other sessions, adding incoming call");
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
self._addSession(session);
|
|
1815
|
+
|
|
1816
|
+
// Set new session state
|
|
1817
|
+
session._setSessionState("INCOMING");
|
|
1818
|
+
|
|
1819
|
+
// Now fire incomingCall event
|
|
1820
|
+
self._fireIncomingCallEvent(session);
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
// Setup connection state event subscriptions
|
|
1825
|
+
this._UA.on("connecting", function (/* data */) {
|
|
1826
|
+
self._setConnectionState("CONNECTING");
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
this._UA.on("connected", function (/* data */) {
|
|
1830
|
+
self._setConnectionState("CONNECTED");
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
this._UA.on("disconnected", function (/* data */) {
|
|
1834
|
+
self._setConnectionState("DISCONNECTED");
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
this._UA.on("registered", function (/* data */) {
|
|
1838
|
+
self._setConnectionState("REGISTERED");
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
this._UA.on("unregistered", function (/* data */) {
|
|
1842
|
+
self._setConnectionState("UNREGISTERED");
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
this._UA.on("registrationFailed", function (/* data */) {
|
|
1846
|
+
self._setConnectionState("REGISTRATION_FAILED");
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
this._UA.start();
|
|
1850
|
+
this._isStarted = true;
|
|
1851
|
+
this.log.info("Successfully started phone");
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Stops the phone client, gracefully disconnects from the server and terminates any active calls
|
|
1856
|
+
* @throws NotReady - If phone is not started
|
|
1857
|
+
*/
|
|
1858
|
+
stop() {
|
|
1859
|
+
// Check phone is started first
|
|
1860
|
+
if (!this._isStarted) {
|
|
1861
|
+
throw new NotReady("Phone is not started");
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Make sure events are not firing anymore, we can't rely on GC.
|
|
1865
|
+
this._UA.removeAllListeners();
|
|
1866
|
+
|
|
1867
|
+
// Stop the UA
|
|
1868
|
+
this._UA.stop();
|
|
1869
|
+
delete this._UA;
|
|
1870
|
+
|
|
1871
|
+
// Disconnect event client if needed
|
|
1872
|
+
if (!this._eventClient) {
|
|
1873
|
+
this.log.debug("Event client not running, not stopping");
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
this._eventClient.disconnect();
|
|
1878
|
+
delete this._eventClient;
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
this.log.warning("Unable to disconnect event client");
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
this._isStarted = false;
|
|
1884
|
+
this.log.info("Phone client successfully stopped");
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* Start a new call
|
|
1889
|
+
* If you have other active calls they will be set on hold
|
|
1890
|
+
* @param number Number to call
|
|
1891
|
+
* @param params {object} call options
|
|
1892
|
+
* @param {number} [params.record] Recording retention days. The number of days recording will be kept. Setting this to 0 disables recording.
|
|
1893
|
+
* @param {number} [params.explicitCallerId] Force a specific caller id, by providing explicit phone number id
|
|
1894
|
+
* @param {number} [params.explicitShortCallerId] Force a specific caller id, by providing explicit short caller id
|
|
1895
|
+
* @param {number} [params.answerCallTimeout=0] Hangup call with NO ANSWER if call is not picked up before specified interval (seconds). If 0, timeout is disabled.
|
|
1896
|
+
* @param {number} [params.maximumCallCost] Maximum call cost (in customer currency) allowed for call. The call will automatically be stopped when the specified cost has been reached. Setting this to 0 disables checking for maximum cost.
|
|
1897
|
+
* @param {string} [params.metaInfo] Add meta info to call, which will be available in server events and inside call complete webhook (max 256 chars)
|
|
1898
|
+
* @return {Session} object for call
|
|
1899
|
+
* @throws NotReady - If phone is not ready to call
|
|
1900
|
+
* @throws NotAllowed - If there are calls currently connecting
|
|
1901
|
+
*
|
|
1902
|
+
*/
|
|
1903
|
+
call(number, params = {}) {
|
|
1904
|
+
// Check phone is started first
|
|
1905
|
+
if (!this._isStarted) {
|
|
1906
|
+
throw new NotReady("Phone is not started");
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Check we dont have any other connecting calls
|
|
1910
|
+
if (this._checkForConnectingCalls()) {
|
|
1911
|
+
throw NotAllowed("Unable to make new calls while others are connecting");
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Hold other calls if needed
|
|
1915
|
+
this.holdAllCalls();
|
|
1916
|
+
|
|
1917
|
+
const callParams = Object.assign(
|
|
1918
|
+
{
|
|
1919
|
+
record: false,
|
|
1920
|
+
maximumCallCost: 0,
|
|
1921
|
+
answerCallTimeout: 0,
|
|
1922
|
+
explicitCallerId: null,
|
|
1923
|
+
explicitShortCallerId: null,
|
|
1924
|
+
metaInfo: null,
|
|
1925
|
+
_isListenCall: false,
|
|
1926
|
+
},
|
|
1927
|
+
params,
|
|
1928
|
+
);
|
|
1929
|
+
|
|
1930
|
+
// Trim phone number to remove spaces and '()'
|
|
1931
|
+
number = number.trim().replace(/ |\(|\)/g, "");
|
|
1932
|
+
|
|
1933
|
+
this.log.info("Calling number: " + number);
|
|
1934
|
+
|
|
1935
|
+
let session = new Session(this);
|
|
1936
|
+
session.newOutboundSession(number, callParams);
|
|
1937
|
+
this._addSession(session);
|
|
1938
|
+
return session;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Start a new session and listen to another call
|
|
1943
|
+
* @param listenKey provided by API endpoint "active-calls" and the event library subscription for resource name "activecall"
|
|
1944
|
+
* @return {Session} object for listen
|
|
1945
|
+
* @throws NotReady - If phone is not ready to call
|
|
1946
|
+
* @throws NotAllowed - If there are calls currently connecting
|
|
1947
|
+
*/
|
|
1948
|
+
listen(listenKey) {
|
|
1949
|
+
const s = listenKey.split("@");
|
|
1950
|
+
let callUUID = s[0];
|
|
1951
|
+
let mediaSwitch = "sip:" + s[1];
|
|
1952
|
+
|
|
1953
|
+
this.log.debug("Initiating listen to call with uuid " + callUUID + " on media switch " + mediaSwitch);
|
|
1954
|
+
return this.call("listen-" + callUUID, {
|
|
1955
|
+
mediaSwitchURI: mediaSwitch,
|
|
1956
|
+
_isListenCall: true,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Get session by UUID, if none are found null is returned
|
|
1962
|
+
* @param uuid
|
|
1963
|
+
* @returns {Session|null}
|
|
1964
|
+
*/
|
|
1965
|
+
getSessionByUUID(uuid) {
|
|
1966
|
+
for (let key in this._activeSessions) {
|
|
1967
|
+
if (this._activeSessions.hasOwnProperty(key)) {
|
|
1968
|
+
if (this._activeSessions[key].uuid === uuid) {
|
|
1969
|
+
return this._activeSessions[key];
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
return null;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Return phone options
|
|
1979
|
+
* @returns {phoneOptions}
|
|
1980
|
+
*/
|
|
1981
|
+
get options() {
|
|
1982
|
+
return this._options;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Return list of current sessions
|
|
1987
|
+
* @returns [Session]
|
|
1988
|
+
*/
|
|
1989
|
+
get sessions() {
|
|
1990
|
+
let s = [];
|
|
1991
|
+
for (let key in this._activeSessions) {
|
|
1992
|
+
if (this._activeSessions.hasOwnProperty(key)) {
|
|
1993
|
+
s.push(this._activeSessions[key]);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
return s;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Return media permission state
|
|
2002
|
+
* @return {boolean}
|
|
2003
|
+
*/
|
|
2004
|
+
get hasMediaPermission() {
|
|
2005
|
+
return this._mediaPermission;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Media permission denied
|
|
2010
|
+
* @return {boolean}
|
|
2011
|
+
*/
|
|
2012
|
+
get hasMediaPermissionError() {
|
|
2013
|
+
return this._mediaPermissionError;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* Phone client started
|
|
2018
|
+
* @return {boolean}
|
|
2019
|
+
*/
|
|
2020
|
+
get isStarted() {
|
|
2021
|
+
return this._isStarted;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* Verify browser is supported
|
|
2026
|
+
* @return true if browser supported
|
|
2027
|
+
*/
|
|
2028
|
+
get browserSupported() {
|
|
2029
|
+
return browserIsChrome() || browserIsFirefox() || browserIsOpera() || browserIsSafari();
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Remote audio HTML element
|
|
2034
|
+
*/
|
|
2035
|
+
get remoteAudioElement() {
|
|
2036
|
+
return this._remoteAudioElement;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// TONE STUFF
|
|
2040
|
+
/**
|
|
2041
|
+
* Play tone locally (on incoming remote audio)
|
|
2042
|
+
* @param duration Duration of tone in ms
|
|
2043
|
+
* @param freq1 Frequency in Hz of tone 1
|
|
2044
|
+
* @param freq2 Frequency in Hz of tone 2
|
|
2045
|
+
* @param callback Function to be called after tone has ended
|
|
2046
|
+
* @throws NotAllowed - If audio api is not supported by browser
|
|
2047
|
+
*/
|
|
2048
|
+
playTone(duration, freq1, freq2 = 0, callback = null) {
|
|
2049
|
+
this.log.debug(`Playing tone ${freq1} / ${freq2} for ${duration} ms`);
|
|
2050
|
+
|
|
2051
|
+
this._oscillator1.frequency.value = freq1;
|
|
2052
|
+
this._oscillator2.frequency.value = freq2;
|
|
2053
|
+
this._toneGainProcessor.gain.value = this._options.toneVolume;
|
|
2054
|
+
|
|
2055
|
+
// Set timer to stop tone again
|
|
2056
|
+
const self = this;
|
|
2057
|
+
this._toneTimer = setTimeout(function () {
|
|
2058
|
+
self.stopTone();
|
|
2059
|
+
|
|
2060
|
+
// Handle callback now
|
|
2061
|
+
if (callback) {
|
|
2062
|
+
callback();
|
|
2063
|
+
}
|
|
2064
|
+
}, duration);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* Immediately stop playing tones
|
|
2069
|
+
* (Tone callbacks will be cancelled)
|
|
2070
|
+
*/
|
|
2071
|
+
stopTone() {
|
|
2072
|
+
this._oscillator1.frequency.value = 0;
|
|
2073
|
+
this._oscillator2.frequency.value = 0;
|
|
2074
|
+
this._toneGainProcessor.gain.value = 0;
|
|
2075
|
+
clearTimeout(this._toneTimer);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// Begin repeating tones
|
|
2079
|
+
_startRepeatTone(sequences) {
|
|
2080
|
+
this.log.info("Repeating tone sequence started");
|
|
2081
|
+
this.log.debug(sequences);
|
|
2082
|
+
this._repeatToneSequence = sequences;
|
|
2083
|
+
this._repeatToneSequencePos = 0;
|
|
2084
|
+
this._repeatToneActive = true;
|
|
2085
|
+
this._doRepeatTone();
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// Do one tone
|
|
2089
|
+
_doRepeatTone() {
|
|
2090
|
+
if (this._repeatToneActive) {
|
|
2091
|
+
this.log.debug(`Playing repeated tone, pos = ${this._repeatToneSequencePos}`);
|
|
2092
|
+
|
|
2093
|
+
// Get tone definitions and move to next sequence position
|
|
2094
|
+
let toneDef = this._repeatToneSequence[this._repeatToneSequencePos];
|
|
2095
|
+
this._repeatToneSequencePos++;
|
|
2096
|
+
if (this._repeatToneSequencePos === this._repeatToneSequence.length) {
|
|
2097
|
+
this._repeatToneSequencePos = 0;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
const self = this;
|
|
2101
|
+
this.playTone(toneDef.d, toneDef.f1, toneDef.f2, function () {
|
|
2102
|
+
setTimeout(self._doRepeatTone.bind(self), toneDef.g);
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// End repeating tones
|
|
2108
|
+
_endRepeatTone() {
|
|
2109
|
+
if (this._repeatToneActive) {
|
|
2110
|
+
this.log.info("Repeating tone stopped");
|
|
2111
|
+
this._repeatToneActive = false;
|
|
2112
|
+
this.stopTone();
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Initialize tone generator
|
|
2117
|
+
_initToneGenerator() {
|
|
2118
|
+
// Create audio context
|
|
2119
|
+
const AudioContext = window.AudioContext || window.webkitAudioContext || false;
|
|
2120
|
+
if (!AudioContext) {
|
|
2121
|
+
throw new NotAllowed("Audio API not supported by this browser");
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const ctx = new AudioContext();
|
|
2125
|
+
this._toneStreamNode = ctx.createMediaStreamDestination();
|
|
2126
|
+
this._toneAudioElement.srcObject = this._toneStreamNode.stream;
|
|
2127
|
+
|
|
2128
|
+
this._toneGainProcessor = ctx.createGain();
|
|
2129
|
+
this._toneGainProcessor.gain.value = 0;
|
|
2130
|
+
this._toneGainProcessor.connect(this._toneStreamNode);
|
|
2131
|
+
|
|
2132
|
+
// Create two oscillators and start them
|
|
2133
|
+
this._oscillator1 = ctx.createOscillator();
|
|
2134
|
+
this._oscillator1.connect(this._toneGainProcessor);
|
|
2135
|
+
this._oscillator1.frequency.value = 0;
|
|
2136
|
+
this._oscillator1.start();
|
|
2137
|
+
|
|
2138
|
+
this._oscillator2 = ctx.createOscillator();
|
|
2139
|
+
this._oscillator2.connect(this._toneGainProcessor);
|
|
2140
|
+
this._oscillator2.frequency.value = 0;
|
|
2141
|
+
this._oscillator2.start();
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
* Hold all active calls
|
|
2146
|
+
* @throws NotAllowed If there are connection calls
|
|
2147
|
+
*/
|
|
2148
|
+
holdAllCalls() {
|
|
2149
|
+
this.log.info("Holding all active sessions");
|
|
2150
|
+
// Check all session can be set on hold
|
|
2151
|
+
if (this._checkForConnectingCalls()) {
|
|
2152
|
+
throw NotAllowed("A session is currently connecting");
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// Iterate through all active calls and hold them
|
|
2156
|
+
for (let key in this._activeSessions) {
|
|
2157
|
+
if (this._activeSessions.hasOwnProperty(key)) {
|
|
2158
|
+
let session = this._activeSessions[key];
|
|
2159
|
+
if (session.state === "ACTIVE") {
|
|
2160
|
+
session.hold();
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/*
|
|
2167
|
+
* ======= PRIVATE METHODS PHONE CLASS =========================================================================
|
|
2168
|
+
*/
|
|
2169
|
+
|
|
2170
|
+
// Add a new session
|
|
2171
|
+
_addSession(session) {
|
|
2172
|
+
/**
|
|
2173
|
+
* @event sessionCreated
|
|
2174
|
+
* @type {object}
|
|
2175
|
+
* @param {session} session - New session created
|
|
2176
|
+
*/
|
|
2177
|
+
// Give session unique id
|
|
2178
|
+
this._sessionCounter++;
|
|
2179
|
+
session.__containerId = this._sessionCounter;
|
|
2180
|
+
|
|
2181
|
+
this.log.info(`Adding new session ${session.__containerId}: ${session.uuid}`);
|
|
2182
|
+
this._activeSessions[session.__containerId] = session;
|
|
2183
|
+
this.log.debug(`We now have ${Object.keys(this._activeSessions).length} sessions:`);
|
|
2184
|
+
this.log.debug(this._activeSessions);
|
|
2185
|
+
|
|
2186
|
+
// Fire event
|
|
2187
|
+
try {
|
|
2188
|
+
this.emit("sessionCreated", { session: session });
|
|
2189
|
+
} catch (e) {
|
|
2190
|
+
this.log.error("Exception in sessionCreated event: " + e);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Remove session
|
|
2195
|
+
_removeSession(session) {
|
|
2196
|
+
/**
|
|
2197
|
+
* @event sessionRemoved
|
|
2198
|
+
* @type {object}
|
|
2199
|
+
* @param {session} session - Session removed
|
|
2200
|
+
*/
|
|
2201
|
+
|
|
2202
|
+
this.log.info(`Removing session ${session.__containerId}: ${session.uuid}`);
|
|
2203
|
+
|
|
2204
|
+
// Fire event
|
|
2205
|
+
try {
|
|
2206
|
+
this.emit("sessionRemoved", { session: session });
|
|
2207
|
+
} catch (e) {
|
|
2208
|
+
this.log.error("Exception in sessionRemoved event: " + e);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
delete this._activeSessions[session.__containerId];
|
|
2212
|
+
this.log.debug(`We now have ${Object.keys(this._activeSessions).length} sessions`);
|
|
2213
|
+
this.log.debug(this._activeSessions);
|
|
2214
|
+
|
|
2215
|
+
// Stop any repeating tones
|
|
2216
|
+
this._endRepeatTone();
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Session call this when call is finished
|
|
2220
|
+
_sessionTerminated(session) {
|
|
2221
|
+
this.log.debug(`Session ${session.uuid} terminated, removing in ${this._options.autoRemoveSessionTime} ms`);
|
|
2222
|
+
const self = this;
|
|
2223
|
+
setTimeout(function () {
|
|
2224
|
+
self._removeSession(session);
|
|
2225
|
+
}, this._options.autoRemoveSessionTime);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// Return true if there are calls currently connecting
|
|
2229
|
+
_checkForConnectingCalls() {
|
|
2230
|
+
for (let key in this._activeSessions) {
|
|
2231
|
+
if (this._activeSessions.hasOwnProperty(key)) {
|
|
2232
|
+
let session = this._activeSessions[key];
|
|
2233
|
+
if (session.state === "INITIALIZING" || session.state === "CALLING" || session.state === "PROGRESS" || session.state === "INCOMING") {
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
return false;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* @typedef {string} CONNECTION_STATE
|
|
2243
|
+
* @property {string} CONNECTING Connecting to server
|
|
2244
|
+
* @property {string} CONNECTED Connected to server
|
|
2245
|
+
* @property {string} DISCONNECTED Disconnected from server
|
|
2246
|
+
* @property {string} REGISTERED Registered on server
|
|
2247
|
+
* @property {string} UNREGISTERED De-registered from server
|
|
2248
|
+
* @property {string} REGISTRATION_FAILED Registration failed
|
|
2249
|
+
*/
|
|
2250
|
+
// Set connection state and emit event
|
|
2251
|
+
_setConnectionState(state) {
|
|
2252
|
+
/**
|
|
2253
|
+
* @event connectionStateChange
|
|
2254
|
+
* @type {object}
|
|
2255
|
+
* @param {CONNECTION_STATE} state - The current connection state
|
|
2256
|
+
*/
|
|
2257
|
+
|
|
2258
|
+
this.log.info("Connection state: " + state);
|
|
2259
|
+
this._connectionState = state;
|
|
2260
|
+
try {
|
|
2261
|
+
this.emit("connectionStateChange", { state: state });
|
|
2262
|
+
} catch (e) {
|
|
2263
|
+
this.log.error("Exception in connectionStateChange event: " + e);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* @event incomingCall
|
|
2269
|
+
* @type {object}
|
|
2270
|
+
* @param {Session} session New incoming session
|
|
2271
|
+
*/
|
|
2272
|
+
_fireIncomingCallEvent(session) {
|
|
2273
|
+
try {
|
|
2274
|
+
this.emit("incomingCall", session);
|
|
2275
|
+
} catch (e) {
|
|
2276
|
+
this.log.error("Exception in incomingCall event: " + e);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Fire call data event to subscribers
|
|
2281
|
+
_fireCallDataInfoEvent(eventData = null) {
|
|
2282
|
+
/**
|
|
2283
|
+
* @event callDataInfo
|
|
2284
|
+
*
|
|
2285
|
+
* @type {object}
|
|
2286
|
+
* @property {uuid} uuid - Customer Call UUID for session
|
|
2287
|
+
* @param {CALL_DIRECTION} direction - Call direction
|
|
2288
|
+
* @param {CALL_DISPOSITION} disposition - The final call disposition
|
|
2289
|
+
* @param {string} originatingNumber - Caller ID number (a-number) used for call
|
|
2290
|
+
* @param {string} destinationNumber - Destination number (b-number) used for call
|
|
2291
|
+
* @param {Date} startTime - Start time of call
|
|
2292
|
+
* @param {Date} answerTime - Time call was answered
|
|
2293
|
+
* @param {Date} endTime - End time of call
|
|
2294
|
+
* @param {number} duration - Total duration of the call (including call setup and remote answer time) (millisec)
|
|
2295
|
+
* @param {String} durationStr - Total duration of the call formatted as (mm:ss)
|
|
2296
|
+
* @param {number} answerDuration - Time before call was answered (millisec)
|
|
2297
|
+
* @param {String} answerDurationStr - Time before call was answered formatted as (mm:ss)
|
|
2298
|
+
* @param {number} activeDuration - Active duration of the call (time spend talking) (millisec)
|
|
2299
|
+
* @param {String} activeDurationStr - Active duration of the call formatted as (mm:ss)
|
|
2300
|
+
* @param {number} rateDestinationId - Rate destination id
|
|
2301
|
+
* @param {string} rateDestinationStr - Rate destination name
|
|
2302
|
+
* @param {number} rateDestinationCost - Rate destination cost / minute
|
|
2303
|
+
* @param {number} callConnectFee - Call attempt/connect cost
|
|
2304
|
+
* @param {number} effectiveCidId - Phone number id used as a-number for call
|
|
2305
|
+
* @param {number} effectiveCidNumberStr - Phone number used as a-number for call
|
|
2306
|
+
* @param {string} effectiveCidNameStr - Phone number name used as a-number for call
|
|
2307
|
+
* @param {boolean} callPrivacy - Call privacy used for call (number hidden)
|
|
2308
|
+
* @param {number} hangupCauseQ850 - Q.850 hangup cause code
|
|
2309
|
+
* @param {Object[]} recordings - List of recordings done for this call
|
|
2310
|
+
* @param {number} recordings[].startTime - Time recording was started
|
|
2311
|
+
* @param {number} recordings[].length - Recording length in milliseconds
|
|
2312
|
+
* @param {number} recordings[].size - Recording size (bytes)
|
|
2313
|
+
* @param {number} recordings[].urlPlayer - URL for recording with player
|
|
2314
|
+
* @param {number} recordings[].url - URL for recording without player
|
|
2315
|
+
* @param {number} recordings[].retentionDate - Date recording will be automatically deleted
|
|
2316
|
+
*/
|
|
2317
|
+
|
|
2318
|
+
let e;
|
|
2319
|
+
e = camelcaseKeys(eventData);
|
|
2320
|
+
e.recordings = camelcaseKeys(eventData.recordings);
|
|
2321
|
+
|
|
2322
|
+
// Event all duration fields are in seconds, convert to millisecs
|
|
2323
|
+
e.duration = e.duration * 1000;
|
|
2324
|
+
e.answerDuration = e.answerDuration * 1000;
|
|
2325
|
+
e.activeDuration = e.activeDuration * 1000;
|
|
2326
|
+
|
|
2327
|
+
// Add some extra convenience fields
|
|
2328
|
+
e.durationStr = createDurationString(e.duration);
|
|
2329
|
+
e.answerDurationStr = e.answerDuration ? createDurationString(e.answerDuration) : "";
|
|
2330
|
+
e.activeDurationStr = e.activeDuration ? createDurationString(e.activeDuration) : "";
|
|
2331
|
+
try {
|
|
2332
|
+
this.emit("callDataInfo", e);
|
|
2333
|
+
} catch (e) {
|
|
2334
|
+
this.log.error("Exception in callDataInfo event: " + e);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// Check if we have media permission
|
|
2339
|
+
_checkMediaPermission() {
|
|
2340
|
+
// Check media is prepared
|
|
2341
|
+
if (!this._mediaPermission) {
|
|
2342
|
+
throw new MediaNotPrepared();
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** Session class created for every active call. You should use either call() or event incomingCall() to get new sessions
|
|
2348
|
+
*
|
|
2349
|
+
* @fires callEnded Receive basic information about call after call has been terminated
|
|
2350
|
+
* @fires callDurationUpdate Regular updates on call duration (every second)
|
|
2351
|
+
* @fires sessionStateChange Changes to session state
|
|
2352
|
+
* @fires recordingStateChange When recording state changes
|
|
2353
|
+
* @fires muteStateChange When mute state changes
|
|
2354
|
+
* @fires holdStateChange When call is hold/unload
|
|
2355
|
+
*
|
|
2356
|
+
*/
|
|
2357
|
+
class Session extends EventEmitter {
|
|
2358
|
+
constructor(phone) {
|
|
2359
|
+
super();
|
|
2360
|
+
|
|
2361
|
+
// Parent Phone object
|
|
2362
|
+
this._phone = phone;
|
|
2363
|
+
|
|
2364
|
+
// Use log instance from phone object
|
|
2365
|
+
this.log = phone.log;
|
|
2366
|
+
|
|
2367
|
+
this._callUUID = null;
|
|
2368
|
+
this._webRTCSession = null;
|
|
2369
|
+
this._sessionState = null;
|
|
2370
|
+
this._iceCandidateTimer = null;
|
|
2371
|
+
this._isOnHold = false;
|
|
2372
|
+
|
|
2373
|
+
// Audio device flags
|
|
2374
|
+
this._isMuted = false;
|
|
2375
|
+
this._isRecording = false;
|
|
2376
|
+
this._recordingError = null;
|
|
2377
|
+
|
|
2378
|
+
// Call timers
|
|
2379
|
+
this._callBeginTime = null;
|
|
2380
|
+
this._callAnswerTime = null;
|
|
2381
|
+
this._callEndTime = null;
|
|
2382
|
+
this._durationUpdTimerObj = null;
|
|
2383
|
+
|
|
2384
|
+
// Call information
|
|
2385
|
+
this._originatingNumber = null;
|
|
2386
|
+
this._destinationNumber = null;
|
|
2387
|
+
this._callDisposition = null;
|
|
2388
|
+
this._localNumberName = null;
|
|
2389
|
+
this._isListenCall = false;
|
|
2390
|
+
this._whisperMode = null;
|
|
2391
|
+
this._manualDisposition = null;
|
|
2392
|
+
|
|
2393
|
+
// Quality report
|
|
2394
|
+
this._rtcpStatsTimer = null;
|
|
2395
|
+
this._currentReport = null;
|
|
2396
|
+
|
|
2397
|
+
// Timeout
|
|
2398
|
+
this._answerCallTimeout = 0;
|
|
2399
|
+
this._answerCallTimer = null;
|
|
2400
|
+
|
|
2401
|
+
// Current media switch handling the call
|
|
2402
|
+
this._mediaSwitch = null;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Create a new outbound session
|
|
2406
|
+
newOutboundSession(number, callParams) {
|
|
2407
|
+
// First create a UUID
|
|
2408
|
+
this._callUUID = createUUID();
|
|
2409
|
+
this.log.debug(`Created new outbound session to: ${number} [${this._callUUID}]`);
|
|
2410
|
+
this.destinationNumber = number;
|
|
2411
|
+
|
|
2412
|
+
let options = {
|
|
2413
|
+
mediaConstraints: { audio: true, video: false },
|
|
2414
|
+
pcConfig: {
|
|
2415
|
+
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
|
|
2416
|
+
},
|
|
2417
|
+
extraHeaders: ["X-Call-token: " + this._phone.options.callToken, "X-P-UUID: " + this._callUUID, "X-Max-cost: " + callParams.maximumCallCost],
|
|
2418
|
+
};
|
|
2419
|
+
|
|
2420
|
+
// Append explicit caller id if needed
|
|
2421
|
+
if (callParams.explicitCallerId) {
|
|
2422
|
+
options.extraHeaders.push("X-CID-Number-Id: " + callParams.explicitCallerId);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// Append explicit short cid if needed
|
|
2426
|
+
if (callParams.explicitShortCallerId) {
|
|
2427
|
+
options.extraHeaders.push("X-Short-CID-Id: " + callParams.explicitShortCallerId);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Append meta info
|
|
2431
|
+
if (callParams.metaInfo) {
|
|
2432
|
+
options.extraHeaders.push("X-Meta-Info: " + encodeURI(callParams.metaInfo.toString().substr(0, 255)));
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// Enable recording?
|
|
2436
|
+
if (callParams.record) {
|
|
2437
|
+
if (isNaN(callParams.record)) {
|
|
2438
|
+
throw new InvalidParameter("Record parameter must be a number");
|
|
2439
|
+
}
|
|
2440
|
+
this.log.debug(`Recording enabled and kept for ${callParams.record} days`);
|
|
2441
|
+
options.extraHeaders.push("X-Record: " + callParams.record);
|
|
2442
|
+
this._isRecording = true;
|
|
2443
|
+
} else {
|
|
2444
|
+
this._isRecording = false;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Store listen flag
|
|
2448
|
+
this._isListenCall = callParams._isListenCall;
|
|
2449
|
+
|
|
2450
|
+
// Manually specified media switch
|
|
2451
|
+
if (callParams.mediaSwitchURI) {
|
|
2452
|
+
this.log.debug("Requesting specific media switch " + callParams.mediaSwitchURI);
|
|
2453
|
+
options.extraHeaders.push("X-RMS: " + callParams.mediaSwitchURI);
|
|
2454
|
+
} else {
|
|
2455
|
+
// Request specific media switch to group calls on same server
|
|
2456
|
+
const otherSessions = this.getOtherSessions();
|
|
2457
|
+
|
|
2458
|
+
if (otherSessions.length > 0) {
|
|
2459
|
+
if (otherSessions[0]._mediaSwitch) {
|
|
2460
|
+
// There are existing active calls, append first media switch address found and request call grouping to this
|
|
2461
|
+
this.log.debug("Requesting call grouping on media switch " + otherSessions[0]._mediaSwitch);
|
|
2462
|
+
options.extraHeaders.push("X-RMS: sip:" + otherSessions[0]._mediaSwitch);
|
|
2463
|
+
} else {
|
|
2464
|
+
this.log.warning("Other sessions exist but media switch address is invalid");
|
|
2465
|
+
}
|
|
2466
|
+
} else {
|
|
2467
|
+
this.log.debug("No other active calls, no media switch grouping requested");
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Prepare invite uri
|
|
2472
|
+
const uri = "sip:" + number + "@telegenta.com";
|
|
2473
|
+
|
|
2474
|
+
// Not start call and create sip session
|
|
2475
|
+
try {
|
|
2476
|
+
this._initWebRTCSession(this._phone._UA.call(uri, options));
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
this.log.error("Error starting call:", error);
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
if (callParams.answerCallTimeout) {
|
|
2483
|
+
this._setAnswerCallTimeout(callParams.answerCallTimeout);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// Create new inbound session
|
|
2488
|
+
newInboundSession(newSessionRequest) {
|
|
2489
|
+
this._callUUID = newSessionRequest.request.getHeader("X-P-UUID", 0);
|
|
2490
|
+
this._destinationNumber = newSessionRequest.request.getHeader("X-DID", 0);
|
|
2491
|
+
this._localNumberName = unescape(newSessionRequest.request.getHeader("X-DID-NAME", 0));
|
|
2492
|
+
this._originName = newSessionRequest.request.from.display_name;
|
|
2493
|
+
this._originatingNumber = newSessionRequest.request.from.uri.user;
|
|
2494
|
+
this.log.info(`Incoming call from ${this._originatingNumber} [${this._callUUID}]`);
|
|
2495
|
+
this.log.debug(newSessionRequest);
|
|
2496
|
+
|
|
2497
|
+
// Get media switch info
|
|
2498
|
+
this._mediaSwitch = newSessionRequest.request.getHeader("X-MS");
|
|
2499
|
+
this.log.debug(`Media switch handling call: ${this._mediaSwitch}`);
|
|
2500
|
+
|
|
2501
|
+
this._initWebRTCSession(newSessionRequest.session);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Return a list of all sessions except this session
|
|
2506
|
+
* Mostly used when transferring call
|
|
2507
|
+
*/
|
|
2508
|
+
getOtherSessions() {
|
|
2509
|
+
const allSessions = this._phone.sessions;
|
|
2510
|
+
|
|
2511
|
+
let s = [];
|
|
2512
|
+
for (let key in allSessions) {
|
|
2513
|
+
if (allSessions[key]) {
|
|
2514
|
+
if (allSessions[key].__containerId !== this.__containerId) {
|
|
2515
|
+
s.push(allSessions[key]);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
return s;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// Initialize with WebRTCSession object
|
|
2524
|
+
_initWebRTCSession(webRTCSession) {
|
|
2525
|
+
this._webRTCSession = webRTCSession;
|
|
2526
|
+
this._callBeginTime = Date.now();
|
|
2527
|
+
|
|
2528
|
+
// Hookup events
|
|
2529
|
+
const self = this;
|
|
2530
|
+
|
|
2531
|
+
// ICE CANDIDATES
|
|
2532
|
+
this._webRTCSession.on("icecandidate", function (event) {
|
|
2533
|
+
const c = event.candidate;
|
|
2534
|
+
self.log.debug(`Got ICE candidate: ${c.candidate}`);
|
|
2535
|
+
if (c.type === "srflx" && c.relatedAddress !== null && c.relatedPort !== null) {
|
|
2536
|
+
self.log.info(`Accepting ICE candidate: ${c.candidate}`);
|
|
2537
|
+
|
|
2538
|
+
// Clear timer first
|
|
2539
|
+
if (self._iceCandidateTimer != null) clearTimeout(self._iceCandidateTimer);
|
|
2540
|
+
|
|
2541
|
+
// Now fire ready
|
|
2542
|
+
event.ready();
|
|
2543
|
+
} else {
|
|
2544
|
+
// Start timeout
|
|
2545
|
+
if (self._iceCandidateTimer != null) clearTimeout(self._iceCandidateTimer);
|
|
2546
|
+
|
|
2547
|
+
self._iceCandidateTimer = setTimeout(
|
|
2548
|
+
function (data) {
|
|
2549
|
+
self.log.debug(`ICE Timeout (${self._phone.options.iceGatherTimeout} ms) reached`);
|
|
2550
|
+
data.ready();
|
|
2551
|
+
},
|
|
2552
|
+
self._phone.options.iceGatherTimeout,
|
|
2553
|
+
event,
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
this._webRTCSession.on("connecting", function (/* data */) {
|
|
2559
|
+
self._startDurationUpdateTimer();
|
|
2560
|
+
self._startRTCPStatsUpdateTimer();
|
|
2561
|
+
self._setSessionState("INITIALIZING");
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
this._webRTCSession.on("sending", function (/* data */) {
|
|
2565
|
+
// Play call initialized tone if needed
|
|
2566
|
+
if (self._phone.options.playCallInitializedTone) {
|
|
2567
|
+
self._phone._startRepeatTone(ST_CALLINIT);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
self._setSessionState("CALLING");
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
this._webRTCSession.on("progress", function (data) {
|
|
2574
|
+
if (data.originator === "remote") {
|
|
2575
|
+
self._setSessionState("PROGRESS", { code: data.response.status_code });
|
|
2576
|
+
self._phone._endRepeatTone();
|
|
2577
|
+
|
|
2578
|
+
// Ringback handling on 180 RINGING
|
|
2579
|
+
if (data.response.status_code === 180) {
|
|
2580
|
+
self._phone._startRepeatTone(ST_RINGBACK);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Also start answer call timeout
|
|
2584
|
+
if (self._answerCallTimeout) {
|
|
2585
|
+
self._answerCallTimer = setTimeout(function () {
|
|
2586
|
+
self.log.info(`Answer call timeout after ${self._answerCallTimeout} seconds`);
|
|
2587
|
+
try {
|
|
2588
|
+
self.hangup();
|
|
2589
|
+
} catch (e) {
|
|
2590
|
+
self.log.info("No active session, ignoring answer call timeout");
|
|
2591
|
+
}
|
|
2592
|
+
}, self._answerCallTimeout * 1000);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
this._webRTCSession.on("accepted", function (data) {
|
|
2598
|
+
// Always end ringsback
|
|
2599
|
+
self._phone._endRepeatTone();
|
|
2600
|
+
|
|
2601
|
+
self._callAnswerTime = Date.now();
|
|
2602
|
+
|
|
2603
|
+
// Call was answered, clear answer call timeout timer if needed
|
|
2604
|
+
if (self._answerCallTimeout) {
|
|
2605
|
+
clearTimeout(self._answerCallTimer);
|
|
2606
|
+
self._answerCallTimeout = 0;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// Get initial recording state
|
|
2610
|
+
if (data.originator === "remote") {
|
|
2611
|
+
const recState = data.response.getHeader("X-Recording");
|
|
2612
|
+
if (recState === "true") {
|
|
2613
|
+
self.log.debug("Call is being prerecorded");
|
|
2614
|
+
// self._isRecording = true;
|
|
2615
|
+
// self.emit('recordingStateChange', {state: true, error: null});
|
|
2616
|
+
} else {
|
|
2617
|
+
self.log.debug("No call recording started yet");
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Get media switch info
|
|
2621
|
+
self._mediaSwitch = data.response.getHeader("X-MS");
|
|
2622
|
+
self.log.debug(`Media switch handling call: ${self._mediaSwitch}`);
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
this._webRTCSession.on("confirmed", function (/* data */) {
|
|
2627
|
+
self._setSessionState("ACTIVE");
|
|
2628
|
+
});
|
|
2629
|
+
|
|
2630
|
+
this._webRTCSession.on("ended", function (/* data */) {
|
|
2631
|
+
// End any ringsbacks if needed
|
|
2632
|
+
self._phone._endRepeatTone();
|
|
2633
|
+
|
|
2634
|
+
self._callEndTime = Date.now();
|
|
2635
|
+
|
|
2636
|
+
// Clear answer call timeout timer if needed
|
|
2637
|
+
if (self._answerCallTimeout) {
|
|
2638
|
+
clearTimeout(self._answerCallTimer);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
self._setSessionState("TERMINATED");
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
this._webRTCSession.on("failed", function (data) {
|
|
2645
|
+
// End any ringsbacks if needed
|
|
2646
|
+
self._phone._endRepeatTone();
|
|
2647
|
+
|
|
2648
|
+
// self.log.error(data);
|
|
2649
|
+
self._callEndTime = Date.now();
|
|
2650
|
+
|
|
2651
|
+
// Clear answer call timeout timer if needed
|
|
2652
|
+
if (self._answerCallTimeout) {
|
|
2653
|
+
clearTimeout(self._answerCallTimer);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
let m;
|
|
2657
|
+
|
|
2658
|
+
// Append message if available
|
|
2659
|
+
if (data.message) {
|
|
2660
|
+
m = `${data.message.status_code} ${data.message.reason_phrase}`;
|
|
2661
|
+
} else {
|
|
2662
|
+
m = "";
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
self._setSessionState("FAILED", { cause: data.cause, message: m });
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
this._webRTCSession.on("dtmf", function (data) {
|
|
2669
|
+
self.log.debug("Session DTMF", data);
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
this._webRTCSession.on("newInfo", function (data) {
|
|
2673
|
+
self.log.debug("New session info", data);
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
this._webRTCSession.on("hold", function (/* data */) {
|
|
2677
|
+
self._isOnHold = true;
|
|
2678
|
+
self._fireHoldStateEvent();
|
|
2679
|
+
self._setSessionState("ON_HOLD");
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
this._webRTCSession.on("unhold", function (/* data */) {
|
|
2683
|
+
self._isOnHold = false;
|
|
2684
|
+
self._fireHoldStateEvent();
|
|
2685
|
+
self._setSessionState("ACTIVE");
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
this._webRTCSession.on("muted", function (data) {
|
|
2689
|
+
self.log.debug("Microphone muted", data);
|
|
2690
|
+
self._isMuted = true;
|
|
2691
|
+
self._fireMuteStateEvent();
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
this._webRTCSession.on("unmuted", function (data) {
|
|
2695
|
+
self.log.debug("Microphone unmuted", data);
|
|
2696
|
+
self._isMuted = false;
|
|
2697
|
+
self._fireMuteStateEvent();
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
this._webRTCSession.on("reinvite", function (data) {
|
|
2701
|
+
self.log.debug("Session reinvite", data);
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
this._webRTCSession.on("update", function (data) {
|
|
2705
|
+
self.log.debug("Session update", data);
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
this._webRTCSession.on("getusermediafailed", function (data) {
|
|
2709
|
+
self.log.error("Getusermedia failed", data);
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2712
|
+
// If connection connect audio stream
|
|
2713
|
+
if (self._webRTCSession.connection) {
|
|
2714
|
+
self._handleRemoteAudio(self._webRTCSession);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// DEBUG STUFF
|
|
2718
|
+
this.log.debug("WebRTCSession object:");
|
|
2719
|
+
this.log.debug(this);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* Hangup call
|
|
2724
|
+
* @param {CALL_DISPOSITION} disposition - Hangup call with specific disposition
|
|
2725
|
+
* @throws NoActiveSession - If no active session / call exists
|
|
2726
|
+
*/
|
|
2727
|
+
hangup(disposition = null) {
|
|
2728
|
+
if (!disposition) {
|
|
2729
|
+
this.log.info("Hanging up call");
|
|
2730
|
+
this._webRTCSession.terminate();
|
|
2731
|
+
} else {
|
|
2732
|
+
this.log.info(`Hanging up call with disposition ${disposition}`);
|
|
2733
|
+
this._manualDisposition = disposition;
|
|
2734
|
+
this._webRTCSession.terminate({
|
|
2735
|
+
extraHeaders: ["X-HDISP: " + disposition],
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/**
|
|
2741
|
+
* Answer incoming call
|
|
2742
|
+
* @param metaInfo - Add meta info to this call
|
|
2743
|
+
*/
|
|
2744
|
+
answer(metaInfo = null) {
|
|
2745
|
+
let options = {
|
|
2746
|
+
mediaConstraints: { audio: true, video: false },
|
|
2747
|
+
pcConfig: {
|
|
2748
|
+
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
|
|
2749
|
+
},
|
|
2750
|
+
extraHeaders: ["X-Call-token: " + this._phone.options.callToken],
|
|
2751
|
+
};
|
|
2752
|
+
|
|
2753
|
+
// Append meta info if needed
|
|
2754
|
+
if (metaInfo) {
|
|
2755
|
+
options.extraHeaders.push("X-Meta-Info: " + encodeURI(metaInfo.toString().substr(0, 255)));
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
this._webRTCSession.answer(options);
|
|
2759
|
+
|
|
2760
|
+
// After answer we start remote audio
|
|
2761
|
+
this._handleRemoteAudio(this._webRTCSession);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Reject incoming call
|
|
2766
|
+
* @param statusCode - SIP Status code
|
|
2767
|
+
* @param reasonPhrase - Reason phrase
|
|
2768
|
+
*/
|
|
2769
|
+
reject(statusCode = 603, reasonPhrase = "Rejected call") {
|
|
2770
|
+
this.log.info("Rejecting call");
|
|
2771
|
+
this._webRTCSession.terminate({
|
|
2772
|
+
status_code: statusCode,
|
|
2773
|
+
reason_phrase: reasonPhrase,
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/**
|
|
2778
|
+
* Return UUID of call
|
|
2779
|
+
* @returns {string} UUID of call
|
|
2780
|
+
*/
|
|
2781
|
+
get uuid() {
|
|
2782
|
+
return this._callUUID;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* Destination number called
|
|
2787
|
+
* @returns {string} called number
|
|
2788
|
+
*/
|
|
2789
|
+
get destinationNumber() {
|
|
2790
|
+
return this._destinationNumber;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/**
|
|
2794
|
+
* Name of local number (for inbound)
|
|
2795
|
+
* @returns {string} Name of local number called
|
|
2796
|
+
*/
|
|
2797
|
+
get localNumberName() {
|
|
2798
|
+
return this._localNumberName;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Originating number
|
|
2803
|
+
* @returns {string} Number of calling party
|
|
2804
|
+
*/
|
|
2805
|
+
get originatingNumber() {
|
|
2806
|
+
return this._originatingNumber;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// Set destination number
|
|
2810
|
+
set destinationNumber(number) {
|
|
2811
|
+
this.log.debug("Setting destination number to: " + number);
|
|
2812
|
+
this._destinationNumber = number;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/**
|
|
2816
|
+
* Session state
|
|
2817
|
+
* @returns {SESSION_STATE} current session state
|
|
2818
|
+
*/
|
|
2819
|
+
get state() {
|
|
2820
|
+
return this._sessionState;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/**
|
|
2824
|
+
* Session call direction
|
|
2825
|
+
* @returns {CALL_DIRECTION} current session state
|
|
2826
|
+
*/
|
|
2827
|
+
get direction() {
|
|
2828
|
+
if (this._isListenCall) {
|
|
2829
|
+
this.log.debug("Call direction is LISTEN");
|
|
2830
|
+
return "LISTEN";
|
|
2831
|
+
} else if (this._webRTCSession.direction === "incoming") {
|
|
2832
|
+
this.log.debug("Call direction is INBOUND");
|
|
2833
|
+
return "INBOUND";
|
|
2834
|
+
} else if (this._webRTCSession.direction === "outgoing") {
|
|
2835
|
+
this.log.debug("Call direction is OUTBOUND");
|
|
2836
|
+
return "OUTBOUND";
|
|
2837
|
+
} else {
|
|
2838
|
+
this.log.error("Unknown call direction: " + this._webRTCSession.direction);
|
|
2839
|
+
return "UNKNOWN";
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// RECORDING
|
|
2844
|
+
/**
|
|
2845
|
+
* Start recording
|
|
2846
|
+
*/
|
|
2847
|
+
startRecording() {
|
|
2848
|
+
const opt = {
|
|
2849
|
+
extraHeaders: ["Record: on"],
|
|
2850
|
+
};
|
|
2851
|
+
|
|
2852
|
+
this.log.debug("Request start recording");
|
|
2853
|
+
|
|
2854
|
+
const self = this;
|
|
2855
|
+
this._webRTCSession.once("newInfo", (data) => {
|
|
2856
|
+
if (data.originator === "local") {
|
|
2857
|
+
data.info.on("succeeded", function (/* data */) {
|
|
2858
|
+
self.log.info("Recording started successfully");
|
|
2859
|
+
self._isRecording = true;
|
|
2860
|
+
try {
|
|
2861
|
+
self.emit("recordingStateChange", { state: true, error: null });
|
|
2862
|
+
} catch (e) {
|
|
2863
|
+
self.log.error("Exception in recordingStateChange event: " + e);
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
data.info.on("failed", function (data) {
|
|
2868
|
+
self._recordingError = data.response.reason_phrase;
|
|
2869
|
+
self.log.error("Recording start failed: " + self._recordingError);
|
|
2870
|
+
self._fireRecordingStateEvent();
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
this._webRTCSession.sendInfo("application/info", null, opt);
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
/**
|
|
2879
|
+
* Stop recording
|
|
2880
|
+
*/
|
|
2881
|
+
stopRecording() {
|
|
2882
|
+
const opt = {
|
|
2883
|
+
extraHeaders: ["Record: off"],
|
|
2884
|
+
};
|
|
2885
|
+
|
|
2886
|
+
this.log.debug("Request stop recording");
|
|
2887
|
+
|
|
2888
|
+
const self = this;
|
|
2889
|
+
|
|
2890
|
+
this._webRTCSession.once("newInfo", (data) => {
|
|
2891
|
+
data.info.on("succeeded", function (/* data */) {
|
|
2892
|
+
self.log.info("Recording stopped successfully");
|
|
2893
|
+
self._isRecording = false;
|
|
2894
|
+
try {
|
|
2895
|
+
self.emit("recordingStateChange", { state: false, error: null });
|
|
2896
|
+
} catch (e) {
|
|
2897
|
+
self.log.error("Exception in recordingStateChange event: " + e);
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
|
|
2901
|
+
data.info.on("failed", function (data) {
|
|
2902
|
+
self.log.error("Recording stopped failed: " + data.response.reason_phrase);
|
|
2903
|
+
try {
|
|
2904
|
+
self.emit("recordingStateChange", { state: self._isRecording, error: data.response.reason_phrase });
|
|
2905
|
+
} catch (e) {
|
|
2906
|
+
self.log.error("Exception in recordingStateChange event: " + e);
|
|
2907
|
+
}
|
|
2908
|
+
});
|
|
2909
|
+
});
|
|
2910
|
+
|
|
2911
|
+
this._webRTCSession.sendInfo("application/info", null, opt);
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/**
|
|
2915
|
+
* Toogle recording ON/OFF
|
|
2916
|
+
*/
|
|
2917
|
+
toggleRecording() {
|
|
2918
|
+
if (!this._isRecording) {
|
|
2919
|
+
this.startRecording();
|
|
2920
|
+
return true;
|
|
2921
|
+
} else {
|
|
2922
|
+
this.stopRecording();
|
|
2923
|
+
return false;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
/**
|
|
2928
|
+
* Return recording state
|
|
2929
|
+
* @return {boolean}
|
|
2930
|
+
*/
|
|
2931
|
+
get isRecording() {
|
|
2932
|
+
return this._isRecording;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// MUTE
|
|
2936
|
+
/**
|
|
2937
|
+
* Mute microphone
|
|
2938
|
+
*/
|
|
2939
|
+
muteMic() {
|
|
2940
|
+
this.log.info("Mute microphone");
|
|
2941
|
+
this._webRTCSession.mute();
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* Unmute microphone
|
|
2946
|
+
*/
|
|
2947
|
+
unmuteMic() {
|
|
2948
|
+
this.log.info("Unmute microphone");
|
|
2949
|
+
this._webRTCSession.unmute();
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
/**
|
|
2953
|
+
* Toggle microphone mute state
|
|
2954
|
+
*/
|
|
2955
|
+
toggleMute() {
|
|
2956
|
+
if (!this._isMuted) {
|
|
2957
|
+
this.muteMic();
|
|
2958
|
+
} else {
|
|
2959
|
+
this.unmuteMic();
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* Return mute state
|
|
2965
|
+
* @return {bool}
|
|
2966
|
+
*/
|
|
2967
|
+
get isMuted() {
|
|
2968
|
+
return this._isMuted;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// HOLD
|
|
2972
|
+
/**
|
|
2973
|
+
* Hold call
|
|
2974
|
+
*/
|
|
2975
|
+
hold() {
|
|
2976
|
+
this.log.info("Hold call");
|
|
2977
|
+
this._webRTCSession.hold();
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/**
|
|
2981
|
+
* Unhold call
|
|
2982
|
+
*/
|
|
2983
|
+
unhold() {
|
|
2984
|
+
this.log.info("Uncall call");
|
|
2985
|
+
this._phone.holdAllCalls();
|
|
2986
|
+
this._webRTCSession.unhold();
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Toggle call hold
|
|
2991
|
+
*/
|
|
2992
|
+
toggleHold() {
|
|
2993
|
+
if (!this._isOnHold) {
|
|
2994
|
+
this.hold();
|
|
2995
|
+
} else {
|
|
2996
|
+
this.unhold();
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* Return call hold status
|
|
3002
|
+
* @return {bool}
|
|
3003
|
+
*/
|
|
3004
|
+
get isOnHold() {
|
|
3005
|
+
return this._isOnHold;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
/**
|
|
3009
|
+
* Send DTMF tone on call
|
|
3010
|
+
* @param code What DTMF code to send (valid: 0-9,*,#,d)
|
|
3011
|
+
* @param playLocal=True Play DTMF sound locally also
|
|
3012
|
+
* @throws InvalidParameter - On incorrect DTMF codes
|
|
3013
|
+
*/
|
|
3014
|
+
sendDTMF(code, playLocal = true) {
|
|
3015
|
+
// Check for correct DTMF and get frequencies
|
|
3016
|
+
if (!(code in DTMF_FREQUENCY_TABLE)) {
|
|
3017
|
+
throw new InvalidParameter("Invalid DTMF code");
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
this.log.info("Sending DTMF: " + code);
|
|
3021
|
+
this._webRTCSession.sendDTMF(code);
|
|
3022
|
+
|
|
3023
|
+
if (playLocal) {
|
|
3024
|
+
const f = DTMF_FREQUENCY_TABLE[code];
|
|
3025
|
+
|
|
3026
|
+
// First mute microphone before playing DTMF
|
|
3027
|
+
if (this._isMuted) {
|
|
3028
|
+
this.log.debug("Mic already muted, just sending DTMF");
|
|
3029
|
+
this._phone.playTone(500, f[0], f[1]);
|
|
3030
|
+
} else {
|
|
3031
|
+
this.log.debug("Mute mic while sending DTMF");
|
|
3032
|
+
this._webRTCSession.mute();
|
|
3033
|
+
|
|
3034
|
+
const self = this;
|
|
3035
|
+
this._phone.playTone(500, f[0], f[1], function () {
|
|
3036
|
+
self.log.debug("Unmute mic after DTMF");
|
|
3037
|
+
self._webRTCSession.unmute();
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/**
|
|
3044
|
+
* Transfer call to another session (attended transfer)
|
|
3045
|
+
*
|
|
3046
|
+
* @param dstSession Destination session
|
|
3047
|
+
*/
|
|
3048
|
+
transfer(dstSession) {
|
|
3049
|
+
this.log.info(`Transferring ${this.uuid} => ${dstSession.uuid}`);
|
|
3050
|
+
|
|
3051
|
+
// Create ReferSubscriber event class
|
|
3052
|
+
const referSubscriber = this._webRTCSession.refer(this._webRTCSession._request.ruri, { replaces: dstSession._webRTCSession });
|
|
3053
|
+
|
|
3054
|
+
const self = this;
|
|
3055
|
+
|
|
3056
|
+
// Setup some logging
|
|
3057
|
+
referSubscriber.on("requestSucceeded", function () {
|
|
3058
|
+
self.log.info("Transfer completed successfully");
|
|
3059
|
+
});
|
|
3060
|
+
|
|
3061
|
+
referSubscriber.on("requestFailed", function (cause) {
|
|
3062
|
+
self.log.error(`Transfer rejected: ${cause}`);
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
referSubscriber.on("failed", function (cause) {
|
|
3066
|
+
self.log.error(`Transfer failed: ${cause}`);
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
/**
|
|
3071
|
+
* Playback media file into call. You must be connected the event service to use this feature (set connectEventService=true)
|
|
3072
|
+
*
|
|
3073
|
+
* @param media_file_id Media file to playback
|
|
3074
|
+
* @param hangup_after Hangup call after playback ends
|
|
3075
|
+
*
|
|
3076
|
+
* @throws NotAllowed - If event service is not enabled
|
|
3077
|
+
*
|
|
3078
|
+
* @return Promise that resolves when playback has started
|
|
3079
|
+
*/
|
|
3080
|
+
playback(media_file_id, hangup_after = false) {
|
|
3081
|
+
// Check event service is enabled first
|
|
3082
|
+
if (!this._phone._options.connectEventService) {
|
|
3083
|
+
throw new NotAllowed("Event service not enabled");
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
this.log.info(`Playback media file ${media_file_id} hangup after ${hangup_after}`);
|
|
3087
|
+
|
|
3088
|
+
const p = this._phone._eventClient.fireEvent("call_command", {
|
|
3089
|
+
call_uuid: this.uuid,
|
|
3090
|
+
call_command: "playback",
|
|
3091
|
+
media_file_id: media_file_id,
|
|
3092
|
+
hangup_after: hangup_after,
|
|
3093
|
+
});
|
|
3094
|
+
|
|
3095
|
+
// When playback is acknowledged and 'hangup_after' is true, we set session state into PLAYBACK
|
|
3096
|
+
if (hangup_after) {
|
|
3097
|
+
// Stop remote audio
|
|
3098
|
+
this._phone._remoteAudioElement.srcObject = null;
|
|
3099
|
+
|
|
3100
|
+
// Mute microphone
|
|
3101
|
+
this._webRTCSession.mute();
|
|
3102
|
+
|
|
3103
|
+
// When playback starts set state to playback
|
|
3104
|
+
p.then(() => {
|
|
3105
|
+
// Set session state
|
|
3106
|
+
this._setSessionState("PLAYBACK");
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
return p;
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
/**
|
|
3114
|
+
* Set whisper mode on "listen" calls
|
|
3115
|
+
*
|
|
3116
|
+
* @param mode Whisper mode 0 = No whisper / 1 = local peer only / 2 = Three way (both local and remote)
|
|
3117
|
+
*
|
|
3118
|
+
* @throws NotAllowed - If call is not listen call
|
|
3119
|
+
* @throws InvalidOptions - If invalid mode given
|
|
3120
|
+
*/
|
|
3121
|
+
setWhisperMode(mode = 0) {
|
|
3122
|
+
if (!this.direction === "LISTEN") {
|
|
3123
|
+
throw new NotAllowed("Call is not correct type (listen)");
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
if (mode < 0 || mode > 2) {
|
|
3127
|
+
throw new InvalidOptions("Invalid mode");
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
let t_dtmf_mode;
|
|
3131
|
+
|
|
3132
|
+
if (mode === 0) {
|
|
3133
|
+
t_dtmf_mode = 0;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
if (mode === 1) {
|
|
3137
|
+
t_dtmf_mode = 2;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
if (mode === 2) {
|
|
3141
|
+
t_dtmf_mode = 3;
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
this.sendDTMF(t_dtmf_mode, false);
|
|
3145
|
+
this._whisperMode = mode;
|
|
3146
|
+
this._fireWhisperModeChangeEvent();
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
/*
|
|
3150
|
+
* ======= PRIVATE METHODS PHONE SESSION =========================================================================
|
|
3151
|
+
*/
|
|
3152
|
+
|
|
3153
|
+
// Begin update timer events
|
|
3154
|
+
_startDurationUpdateTimer() {
|
|
3155
|
+
/**
|
|
3156
|
+
* @event callDurationUpdate
|
|
3157
|
+
* @type {object}
|
|
3158
|
+
* @property {String} uuid - UUID of session
|
|
3159
|
+
* @property {Date} beginTime - Time session was created
|
|
3160
|
+
* @property {Date} answerTime - Time session was answered
|
|
3161
|
+
* @property {Date} duration - The total duration of the session
|
|
3162
|
+
* @property {String} durationStr - The duration of the session string formated (mm:ss)
|
|
3163
|
+
* @property {Date} activeDuration - The active duration of the session
|
|
3164
|
+
* @property {String} activeDurationStr - The active duration of the session string formated (mm:ss)
|
|
3165
|
+
*
|
|
3166
|
+
* @example
|
|
3167
|
+
* // Update duration text on screen
|
|
3168
|
+
* session.on('callDurationUpdate', function (event) {
|
|
3169
|
+
* document.getElementById('duration').textContent = event.durationStr;
|
|
3170
|
+
* });
|
|
3171
|
+
*/
|
|
3172
|
+
|
|
3173
|
+
const self = this;
|
|
3174
|
+
if (!this._durationUpdTimerObj) {
|
|
3175
|
+
this._durationUpdTimerObj = setInterval(function () {
|
|
3176
|
+
const activeDuration = self._callAnswerTime ? Date.now() - self._callAnswerTime : null;
|
|
3177
|
+
const e = {
|
|
3178
|
+
uuid: self._callUUID,
|
|
3179
|
+
beginTime: self._callBeginTime,
|
|
3180
|
+
answerTime: self._callAnswerTime,
|
|
3181
|
+
endTime: self._callEndTime,
|
|
3182
|
+
duration: Date.now() - self._callBeginTime,
|
|
3183
|
+
durationStr: createDurationString(Date.now() - self._callBeginTime),
|
|
3184
|
+
activeDuration: activeDuration,
|
|
3185
|
+
activeDurationStr: activeDuration ? createDurationString(activeDuration) : "",
|
|
3186
|
+
};
|
|
3187
|
+
|
|
3188
|
+
try {
|
|
3189
|
+
self.emit("callDurationUpdate", e);
|
|
3190
|
+
} catch (e) {
|
|
3191
|
+
self.log.error("Exception in call duration update event: " + e);
|
|
3192
|
+
}
|
|
3193
|
+
}, 1000);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// Begin rtcp stats update timer events
|
|
3198
|
+
_startRTCPStatsUpdateTimer() {
|
|
3199
|
+
/**
|
|
3200
|
+
* @event callQualityReportUpdate
|
|
3201
|
+
* @description
|
|
3202
|
+
* Contains information about call quality (fires in interval of 5 sec).
|
|
3203
|
+
* Payload contains "total" (metrics for whole session) and "delta" (metrics last 5 secs).
|
|
3204
|
+
*
|
|
3205
|
+
* @type {object}
|
|
3206
|
+
* @property {String} uuid - UUID of session
|
|
3207
|
+
* @property {Number} outboundPacketsSent - Number of packets sent
|
|
3208
|
+
* @property {Number} outboundBytesSent - Number of bytes sent
|
|
3209
|
+
* @property {Number} outboundPacketsLost - Number of packets lost to remote
|
|
3210
|
+
* @property {Number} outboundPacketLossPct - Percentage of outbound packets lost
|
|
3211
|
+
* @property {Number} outboundJitter - Network jitter to remote (ms)
|
|
3212
|
+
* @property {Number} inboundPacketsReceived - Number of packets received
|
|
3213
|
+
* @property {Number} inboundBytesReceived - Number of bytes received
|
|
3214
|
+
* @property {Number} inboundPacketsLost - Number of packets lost from remote
|
|
3215
|
+
* @property {Number} inboundPacketLossPct - Percentage of inbound packets lost
|
|
3216
|
+
* @property {Number} inboundJitter - Network jitter from remote (ms)
|
|
3217
|
+
* @property {Number} roundTripTime - Latency in ms to media gateway
|
|
3218
|
+
* @property {Number} effectiveLatency - Effective latency with combined jitter impact
|
|
3219
|
+
* @property {Number} rFactor - Quality of media stream (0 - 100%)
|
|
3220
|
+
* @property {Number} mos - Synthetic MOS
|
|
3221
|
+
* @property {String} qualityString - Quality string [Excellent, Good, Fair, Poor, Bad]
|
|
3222
|
+
*
|
|
3223
|
+
* @example
|
|
3224
|
+
* // Update call quality text on screen
|
|
3225
|
+
* session.on('callQualityReportUpdate', (report) => {
|
|
3226
|
+
* document.getElementById('quality_text').textContent = report.delta.qualityString;
|
|
3227
|
+
* document.getElementById('latency').textContent = report.delta.effectiveLatency;
|
|
3228
|
+
* });
|
|
3229
|
+
*/
|
|
3230
|
+
|
|
3231
|
+
if (!this._rtcpStatsTimer) {
|
|
3232
|
+
const emptyReport = {
|
|
3233
|
+
outboundPacketsSent: 0,
|
|
3234
|
+
outboundBytesSent: 0,
|
|
3235
|
+
outboundPacketsLost: 0,
|
|
3236
|
+
outboundJitter: 0,
|
|
3237
|
+
inboundPacketsReceived: 0,
|
|
3238
|
+
inboundBytesReceived: 0,
|
|
3239
|
+
inboundPacketsLost: 0,
|
|
3240
|
+
inboundJitter: 0,
|
|
3241
|
+
roundTripTime: 0,
|
|
3242
|
+
};
|
|
3243
|
+
|
|
3244
|
+
// Initialize report first
|
|
3245
|
+
this._currentReport = Object.assign({}, emptyReport);
|
|
3246
|
+
|
|
3247
|
+
this._rtcpStatsTimer = setInterval(() => {
|
|
3248
|
+
this._webRTCSession._connection.getStats().then((data) => {
|
|
3249
|
+
let totalReport = Object.assign({}, emptyReport);
|
|
3250
|
+
let deltaReport = Object.assign({}, emptyReport);
|
|
3251
|
+
|
|
3252
|
+
for (let report of data.values()) {
|
|
3253
|
+
// this.log.debug(report);
|
|
3254
|
+
switch (report.type) {
|
|
3255
|
+
case "outbound-rtp":
|
|
3256
|
+
// this.log.debug('Got: outbound-rtp');
|
|
3257
|
+
// this.log.debug(report);
|
|
3258
|
+
// Packets
|
|
3259
|
+
totalReport.outboundPacketsSent = report.packetsSent;
|
|
3260
|
+
deltaReport.outboundPacketsSent = report.packetsSent - this._currentReport.outboundPacketsSent;
|
|
3261
|
+
// Bytes
|
|
3262
|
+
totalReport.outboundBytesSent = report.bytesSent;
|
|
3263
|
+
deltaReport.outboundBytesSent = report.bytesSent - this._currentReport.outboundBytesSent;
|
|
3264
|
+
break;
|
|
3265
|
+
|
|
3266
|
+
case "inbound-rtp":
|
|
3267
|
+
// this.log.debug('Got: inbound-rtp');
|
|
3268
|
+
// this.log.debug(report);
|
|
3269
|
+
// Packets
|
|
3270
|
+
totalReport.inboundPacketsReceived = report.packetsReceived;
|
|
3271
|
+
deltaReport.inboundPacketsReceived = report.packetsReceived - this._currentReport.inboundPacketsReceived;
|
|
3272
|
+
// Bytes
|
|
3273
|
+
totalReport.inboundBytesReceived = report.bytesReceived;
|
|
3274
|
+
deltaReport.inboundBytesReceived = report.bytesReceived - this._currentReport.inboundBytesReceived;
|
|
3275
|
+
// Loss
|
|
3276
|
+
totalReport.inboundPacketsLost = report.packetsLost;
|
|
3277
|
+
deltaReport.inboundPacketsLost = report.packetsLost - this._currentReport.inboundPacketsLost;
|
|
3278
|
+
// Jitter
|
|
3279
|
+
totalReport.inboundJitter = report.jitter * 1000;
|
|
3280
|
+
deltaReport.inboundJitter = report.jitter * 1000;
|
|
3281
|
+
break;
|
|
3282
|
+
|
|
3283
|
+
case "remote-inbound-rtp":
|
|
3284
|
+
// this.log.debug('Got: remote-inbound-rtp');
|
|
3285
|
+
// this.log.debug(report);
|
|
3286
|
+
// Loss
|
|
3287
|
+
totalReport.outboundPacketsLost = report.packetsLost;
|
|
3288
|
+
deltaReport.outboundPacketsLost = report.packetsLost - this._currentReport.outboundPacketsLost;
|
|
3289
|
+
// Jitter
|
|
3290
|
+
totalReport.outboundJitter = report.jitter * 1000;
|
|
3291
|
+
deltaReport.outboundJitter = report.jitter * 1000;
|
|
3292
|
+
// RTT
|
|
3293
|
+
totalReport.roundTripTime = report.roundTripTime * 1000;
|
|
3294
|
+
deltaReport.roundTripTime = report.roundTripTime * 1000;
|
|
3295
|
+
break;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
this._currentReport = this._calcReportMetrics(totalReport);
|
|
3300
|
+
|
|
3301
|
+
// We have to wait for RTT before firing the first event
|
|
3302
|
+
if (totalReport.roundTripTime) {
|
|
3303
|
+
// Do current report metrics
|
|
3304
|
+
const finalReport = {
|
|
3305
|
+
total: this._currentReport,
|
|
3306
|
+
delta: this._calcReportMetrics(deltaReport),
|
|
3307
|
+
};
|
|
3308
|
+
|
|
3309
|
+
try {
|
|
3310
|
+
this.emit("callQualityReportUpdate", finalReport);
|
|
3311
|
+
} catch (e) {
|
|
3312
|
+
this.log.error("Exception in call quality report update event: " + e);
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
}, 5000);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
// Return quality string based on R-factor
|
|
3321
|
+
_calcReportMetrics(report) {
|
|
3322
|
+
// Packet loss
|
|
3323
|
+
report.outboundPacketLossPct = report.outboundPacketsLost / report.outboundPacketsSent;
|
|
3324
|
+
report.inboundPacketLossPct = report.inboundPacketsLost / report.inboundPacketsReceived;
|
|
3325
|
+
|
|
3326
|
+
// Effective latency
|
|
3327
|
+
report.effectiveLatency = report.roundTripTime + (report.inboundJitter + report.outboundJitter) + 10;
|
|
3328
|
+
|
|
3329
|
+
// R-Factor and mos
|
|
3330
|
+
const lossRate = (report.outboundPacketLossPct + report.inboundPacketLossPct) * 100;
|
|
3331
|
+
const lossEffect = (95.0 * lossRate) / (lossRate + 10);
|
|
3332
|
+
const latencyEffect = report.effectiveLatency * 0.1;
|
|
3333
|
+
// this.log.debug(`R factor loss effect ${lossEffect} / latency effect ${latencyEffect}`);
|
|
3334
|
+
|
|
3335
|
+
report.rFactor = Math.max(0, 100 - lossEffect - latencyEffect);
|
|
3336
|
+
report.mos = 1 + 0.035 * report.rFactor + 0.000007 * report.rFactor * (report.rFactor - 60) * (100 - report.rFactor);
|
|
3337
|
+
|
|
3338
|
+
// Quality string
|
|
3339
|
+
switch (true) {
|
|
3340
|
+
case report.rFactor < 50:
|
|
3341
|
+
report.qualityString = "Bad";
|
|
3342
|
+
break;
|
|
3343
|
+
case report.rFactor < 70:
|
|
3344
|
+
report.qualityString = "Poor";
|
|
3345
|
+
break;
|
|
3346
|
+
case report.rFactor < 80:
|
|
3347
|
+
report.qualityString = "Fair";
|
|
3348
|
+
break;
|
|
3349
|
+
case report.rFactor < 90:
|
|
3350
|
+
report.qualityString = "Good";
|
|
3351
|
+
break;
|
|
3352
|
+
case report.rFactor <= 100:
|
|
3353
|
+
report.qualityString = "Excellent";
|
|
3354
|
+
break;
|
|
3355
|
+
default:
|
|
3356
|
+
report.qualityString = "Error";
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
return report;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
/**
|
|
3363
|
+
* @typedef SESSION_STATE {string}
|
|
3364
|
+
* @property {string} INITIALIZING Session is initializing (Gather ICE candidates)
|
|
3365
|
+
* @property {string} CALLING Sending INVITE to server
|
|
3366
|
+
* @property {string} PROGRESS Call progress received ('event.code' has new progress status)
|
|
3367
|
+
* @property {string} ACTIVE Call is active (answered)
|
|
3368
|
+
* @property {string} TERMINATED Call ended
|
|
3369
|
+
* @property {string} FAILED Call failed (see 'event.cause' for more info)
|
|
3370
|
+
* @property {string} ON_HOLD Call is on-hold
|
|
3371
|
+
* @property {string} INCOMING Incoming call is waiting
|
|
3372
|
+
* @property {string} PLAYBACK Playback with pending hangup in progress
|
|
3373
|
+
*/
|
|
3374
|
+
|
|
3375
|
+
// Set session state and emit event
|
|
3376
|
+
_setSessionState(state, info = null) {
|
|
3377
|
+
/**
|
|
3378
|
+
* @event sessionStateChange
|
|
3379
|
+
* @type {object}
|
|
3380
|
+
* @param {SESSION_STATE} state - The current session state
|
|
3381
|
+
* @param {uuid} uuid - Call UUID for session
|
|
3382
|
+
* @param {string} originName - Originating name of call [INCOMING only]
|
|
3383
|
+
* @param {string} originatingNumber - Originating phone number of call [INCOMING only]
|
|
3384
|
+
* @param {string} destinationNumber - Destination number of call
|
|
3385
|
+
* @param {string} cause - Fail cause [FAILED only]
|
|
3386
|
+
* @param {string} message - Fail message description [FAILED only]
|
|
3387
|
+
*
|
|
3388
|
+
*/
|
|
3389
|
+
|
|
3390
|
+
this.log.debug("Session state: " + state);
|
|
3391
|
+
if (info) {
|
|
3392
|
+
this.log.debug(info);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
this._sessionState = state;
|
|
3396
|
+
try {
|
|
3397
|
+
this.emit(
|
|
3398
|
+
"sessionStateChange",
|
|
3399
|
+
Object.assign(
|
|
3400
|
+
{
|
|
3401
|
+
state: state,
|
|
3402
|
+
uuid: this._callUUID,
|
|
3403
|
+
},
|
|
3404
|
+
info,
|
|
3405
|
+
),
|
|
3406
|
+
);
|
|
3407
|
+
} catch (e) {
|
|
3408
|
+
this.log.error("Exception in sessionStateChange event:" + e);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// Trigger callDataAvailable dummy event
|
|
3412
|
+
if (state === "TERMINATED") {
|
|
3413
|
+
this._callDisposition = this._manualDisposition || "NORMAL";
|
|
3414
|
+
this._callFinished();
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
if (state === "FAILED") {
|
|
3418
|
+
if (info.cause === "Unavailable") {
|
|
3419
|
+
this._callDisposition = "TEMP_UNAVAIL";
|
|
3420
|
+
if (this._phone.options.playSignalTones) {
|
|
3421
|
+
this._phone._startRepeatTone(ST_TEMP_UNAVAIL);
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
if (info.cause === "Not Found") {
|
|
3426
|
+
this._callDisposition = "INVALID_NUMBER";
|
|
3427
|
+
if (this._phone.options.playSignalTones) {
|
|
3428
|
+
this._phone._startRepeatTone(ST_INVALID_NUMBER);
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
if (info.cause === "Canceled") {
|
|
3433
|
+
this._callDisposition = this._manualDisposition || "NO_ANSWER";
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
if (info.cause === "Rejected") {
|
|
3437
|
+
if (this.direction === "INBOUND") {
|
|
3438
|
+
this._callDisposition = "REJECTED";
|
|
3439
|
+
} else {
|
|
3440
|
+
this._callDisposition = "BARRED";
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
if (info.cause === "Busy") {
|
|
3445
|
+
this._callDisposition = "BUSY";
|
|
3446
|
+
if (this._phone.options.playSignalTones) {
|
|
3447
|
+
this._phone._startRepeatTone(ST_BUSY);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// If session is incoming and failed, we don't fire calldataAvailable event
|
|
3452
|
+
if (this._webRTCSession.direction === "incoming") ;
|
|
3453
|
+
|
|
3454
|
+
this._callFinished();
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* @typedef {string} CALL_DIRECTION
|
|
3460
|
+
* @property {string} INBOUND Incoming call
|
|
3461
|
+
* @property {string} OUTBOUND Outgoing call
|
|
3462
|
+
* @property {string} TRANSFER Transferred call
|
|
3463
|
+
* @property {string} LOCAL Local call
|
|
3464
|
+
* @property {string} AUTOMATED Call triggered from api / server
|
|
3465
|
+
* @property {string} LISTEN Listen call
|
|
3466
|
+
*/
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* @typedef {string} CALL_DISPOSITION
|
|
3470
|
+
* @property {string} BUSY Number was busy
|
|
3471
|
+
* @property {string} INVALID_NUMBER Number call does not exist
|
|
3472
|
+
* @property {string} NO_ANSWER Call was not picked up before cancelling
|
|
3473
|
+
* @property {string} TEMP_UNAVAIL Remote is temporarily unavailable
|
|
3474
|
+
* @property {string} NORMAL Call was terminated normally
|
|
3475
|
+
* @property {string} FAILED There was a failure connecting the call
|
|
3476
|
+
* @property {string} BARRED Call was prohibited by server
|
|
3477
|
+
* @property {string} REJECTED Incoming call was rejected
|
|
3478
|
+
*/
|
|
3479
|
+
_callFinished() {
|
|
3480
|
+
this.log.info("Call finished");
|
|
3481
|
+
|
|
3482
|
+
// Fire callEnded event
|
|
3483
|
+
this._fireCallEndedEvent();
|
|
3484
|
+
|
|
3485
|
+
// Stop duration update timer
|
|
3486
|
+
clearInterval(this._durationUpdTimerObj);
|
|
3487
|
+
|
|
3488
|
+
// Stop rtcp stats uptime timer
|
|
3489
|
+
clearInterval(this._rtcpStatsTimer);
|
|
3490
|
+
|
|
3491
|
+
// Remove session from active sessions
|
|
3492
|
+
this._phone._sessionTerminated(this);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// Set answer call timeout
|
|
3496
|
+
_setAnswerCallTimeout(timeout) {
|
|
3497
|
+
this.log.debug("Setting answer call timeout to: " + timeout);
|
|
3498
|
+
this._answerCallTimeout = timeout;
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
// Fire recording state event
|
|
3502
|
+
_fireRecordingStateEvent() {
|
|
3503
|
+
/**
|
|
3504
|
+
* @event recordingStateChange
|
|
3505
|
+
* @type {object}
|
|
3506
|
+
* @property {boolean} state - New recording state
|
|
3507
|
+
*/
|
|
3508
|
+
try {
|
|
3509
|
+
this.emit("recordingStateChange", { state: this._isRecording, error: this._recordingError });
|
|
3510
|
+
} catch (e) {
|
|
3511
|
+
this.log.error("Exception in recording state change event: " + e);
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
// Fire mic mute state event
|
|
3516
|
+
_fireMuteStateEvent() {
|
|
3517
|
+
/**
|
|
3518
|
+
* @event muteStateChange
|
|
3519
|
+
* @type {object}
|
|
3520
|
+
* @property {boolean} state - Mute state (true = muted / false = unmuted)
|
|
3521
|
+
*/
|
|
3522
|
+
try {
|
|
3523
|
+
this.emit("muteStateChange", { state: this._isMuted });
|
|
3524
|
+
} catch (e) {
|
|
3525
|
+
this.log.error("Exception in mute state change event: " + e);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
// Fire recording state event
|
|
3530
|
+
_fireHoldStateEvent() {
|
|
3531
|
+
/**
|
|
3532
|
+
* @event holdStateChange
|
|
3533
|
+
* @type {object}
|
|
3534
|
+
* @property {boolean} state - New hold state
|
|
3535
|
+
*/
|
|
3536
|
+
try {
|
|
3537
|
+
this.emit("holdStateChange", { state: this._isOnHold });
|
|
3538
|
+
} catch (e) {
|
|
3539
|
+
this.log.error("Exception in hold state change event: " + e);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
// Fire whisper mode event
|
|
3544
|
+
_fireWhisperModeChangeEvent() {
|
|
3545
|
+
/**
|
|
3546
|
+
* @event whisperModeChange
|
|
3547
|
+
* @type {object}
|
|
3548
|
+
* @property {boolean} mode - New whisper mode (0 = No whisper / 1 = Local only / 2 = Three way)
|
|
3549
|
+
*/
|
|
3550
|
+
try {
|
|
3551
|
+
this.emit("whisperModeChange", { mode: this._whisperMode });
|
|
3552
|
+
} catch (e) {
|
|
3553
|
+
this.log.error("Exception in whisper mode change event: " + e);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
// Fire call data event to subscribers
|
|
3558
|
+
_fireCallEndedEvent() {
|
|
3559
|
+
/**
|
|
3560
|
+
* @event callEnded
|
|
3561
|
+
*
|
|
3562
|
+
* @type {object}
|
|
3563
|
+
* @property {uuid} uuid - Customer Call UUID for session
|
|
3564
|
+
* @param {CALL_DIRECTION} direction - Call direction
|
|
3565
|
+
* @param {CALL_DISPOSITION} disposition - The final call disposition
|
|
3566
|
+
* @param {string} originatingNumber - Caller ID number (a-number) used for call
|
|
3567
|
+
* @param {string} destinationNumber - Destination number (b-number) used for call
|
|
3568
|
+
* @param {Date} startTime - Start time of call
|
|
3569
|
+
* @param {Date} answerTime - Time call was answered
|
|
3570
|
+
* @param {Date} endTime - End time of call
|
|
3571
|
+
* @param {number} duration - Total duration of the call (including call setup and remote answer time) (millisec)
|
|
3572
|
+
* @param {String} durationStr - Total duration of the call formatted as (mm:ss)
|
|
3573
|
+
* @param {Number} answerDuration - Time before call was answered (millisec)
|
|
3574
|
+
* @param {String} answerDurationStr - Time before call was answered formatted as (mm:ss)
|
|
3575
|
+
* @param {Number} activeDuration - Active duration of the call (time spend talking) (millisec)
|
|
3576
|
+
* @param {String} activeDurationStr - Active duration of the call formatted as (mm:ss)
|
|
3577
|
+
* @param {object} callQualityReport - Call quality report (see callQualityReportUpdate event for details)
|
|
3578
|
+
*/
|
|
3579
|
+
|
|
3580
|
+
let e;
|
|
3581
|
+
|
|
3582
|
+
e = {
|
|
3583
|
+
uuid: this._callUUID,
|
|
3584
|
+
direction: this.direction,
|
|
3585
|
+
disposition: this._callDisposition,
|
|
3586
|
+
originatingNumber: this._originatingNumber,
|
|
3587
|
+
destinationNumber: this._destinationNumber,
|
|
3588
|
+
|
|
3589
|
+
// Calc durations
|
|
3590
|
+
duration: this._callEndTime - this._callBeginTime,
|
|
3591
|
+
answerDuration: this._callAnswerTime ? this._callAnswerTime - this._callBeginTime : null,
|
|
3592
|
+
activeDuration: this._callAnswerTime ? this._callEndTime - this._callAnswerTime : null,
|
|
3593
|
+
|
|
3594
|
+
// Convert timestamp into ISO8601
|
|
3595
|
+
startTime: new Date(this._callBeginTime).toISOString(),
|
|
3596
|
+
endTime: new Date(this._callEndTime).toISOString(),
|
|
3597
|
+
answerTime: this._callAnswerTime ? new Date(this._callAnswerTime).toISOString() : null,
|
|
3598
|
+
};
|
|
3599
|
+
|
|
3600
|
+
// Add some extra convenience fields
|
|
3601
|
+
e.durationStr = createDurationString(e.duration);
|
|
3602
|
+
e.answerDurationStr = e.answerDuration ? createDurationString(e.answerDuration) : "";
|
|
3603
|
+
e.activeDurationStr = e.activeDuration ? createDurationString(e.activeDuration) : "";
|
|
3604
|
+
e.callQualityReport = this._currentReport;
|
|
3605
|
+
|
|
3606
|
+
try {
|
|
3607
|
+
this.emit("callEnded", e);
|
|
3608
|
+
} catch (e) {
|
|
3609
|
+
this.log.error("Exception in callEnded event: " + e);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
// Setup handling of remote audio
|
|
3614
|
+
_handleRemoteAudio(session) {
|
|
3615
|
+
// Stop any repeating tones if audio comes
|
|
3616
|
+
this._phone._endRepeatTone();
|
|
3617
|
+
|
|
3618
|
+
const self = this;
|
|
3619
|
+
session.connection.ontrack = function (event) {
|
|
3620
|
+
self.log.debug("Start media output (track):");
|
|
3621
|
+
self.log.debug(event);
|
|
3622
|
+
self._phone._remoteAudioElement.srcObject = event.streams[0];
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
export { EventClient, InvalidMediaDevice, InvalidOptions, InvalidParameter, LOGLVL$1 as LOGLVL, LogHandler, MediaNotPrepared, NoActiveSession, NotAllowed, NotReady, Phone, Request, Requestor, Session, UnableToConnect, browserIsChrome, browserIsEdge, browserIsFirefox, browserIsOpera, browserIsSafari, compareObjects, createDurationString, createUUID };
|