@telegenta/webclient 2.0.12 → 3.0.1

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