@telegenta/webclient 3.0.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,63 +39,49 @@ and you are ready to go. The client has no external requirements (like jQuery an
39
39
 
40
40
  Before starting, you will need an account on telegenta.com, to create sip accounts and api tokens.
41
41
 
42
- #### Example 1: Simple phone with static account
42
+ Runnable examples live in `demos/`. They load the browser bundle from `../dist/telegenta.min.js`, so
43
+ run `npm run build` first, then serve the directory over https. WebRTC requires a secure context;
44
+ `localhost` counts as secure, but opening a demo as `file://...` does not - media permissions are not
45
+ remembered there, so you will be prompted for the microphone every time.
43
46
 
44
- In this example we will use a normal SIP User account, as it does not require you to use our API service.
47
+ #### Example: phone with a provisioning token
45
48
 
46
- Log into the administration frontend (https://admin.telegenta.com) and create a "SIP User account", this will provide
47
- you with credentials (username and password). Once you have these open the embedded html client provided with this
48
- library /embedded_client/index.html and modify the credentials:
49
+ `demos/phone_client.html` is a complete softphone - start/stop, outbound calls, transfer, listen-in,
50
+ incoming calls and the event client.
49
51
 
50
- ```
51
- // SIP account username and password
52
- var USERNAME='your username';
53
- var PASSWORD='your password';
54
-
55
- window.onload = function () {
56
- ...
57
- ```
58
-
59
- Now open the file (index.html) in your browser. After you have been prompted for permission to use the audio devices,
60
- you should have a working phone.
61
- Note: If you are viewing a file using "file://index.html", it is not considered a secure connection by the browser
62
- and media permissions are not remembered, so you will be prompted every time you open the file.
63
- However, once you move the file to a secure webserver (https), this will no longer be an issue.
64
-
65
- #### Example 2: Using SIP Flex accounts
52
+ First create a static API token in the admin interface
53
+ (https://admin.telegenta.com/integration/access-tokens). Open the demo and paste the token in; it then
66
54
 
67
- When using SIP flex accounts, you need to use our API service to retrieve account information first.
68
- This approach solves the authentication problem and provides you with short-lived sip accounts used in client sessions.
69
- For this example we need a backend service and we provide a simple example in PHP using the built in Webserver.
55
+ 1. requests a short-lived provisioning token with `GET /api/get-call-session/?id=<account id>`, and
56
+ 2. starts the phone with that token:
70
57
 
71
- First go to the admin interface and create following 2 items:
58
+ ```
59
+ const opts = {
60
+ // Enable event client
61
+ connectEventService: true,
72
62
 
73
- 1. SIP Flex account with "flex account identification" set to 'test'
74
- 2. A static api token (https://admin.telegenta.com/integration/access-tokens).
63
+ // Subscribe to detailed information about call after processing
64
+ useCallDataInfoEvent: true,
75
65
 
76
- Now open the file /embedded_client/server.html and insert the API token at the top of the file:
66
+ // Enable incoming calls (you must set a phone number to call the account also)
67
+ register: true,
77
68
 
78
- ```
79
- // Your API Token
80
- $api_auth_token = '<your api token>';
69
+ // Configure client with provision token
70
+ provisionToken: data.provision_token,
71
+ };
81
72
 
82
- // The flex account identification (this is set when configuring your flex accounts)
83
- // It possible to have multiple sip flex accounts with different configurations
84
- $flex_identification = 'test';
85
- ...
73
+ phoneClient.start(opts);
86
74
  ```
87
75
 
88
- Once you have added the token, you can start the php Webserver (requires php v5.4+) like this:
76
+ Issuing the provisioning token from your own backend is the recommended integration: the short-lived
77
+ token is all that reaches the browser, and your API token stays on the server.
89
78
 
90
- ```
91
- cd embedded_client
92
- php -S localhost:9000
93
- ```
79
+ The remaining demos cover narrower cases:
94
80
 
95
- Now open the URL http://localhost:9000/server.php and you should see the phone and be able to perform calls.
96
- Note: If you use any other host than localhost, you won't be able to call because the built in php webserver does not
97
- support secure (https) connections and this is required by the browser to use WebRTC. If needed, you can tunnel to
98
- localhost using your ssh client for testing.
81
+ - `demos/event_client.html` - the event client on its own, without a phone
82
+ - `demos/calldata_available.html` - subscribing to call data after a call completes
83
+ - `demos/add_phone_number.html` - adding a phone number through the API
84
+ - `demos/embedded_config.html` - embedding the admin config pages with a config token
99
85
 
100
86
  ## Approaching the integration
101
87
 
@@ -106,15 +92,10 @@ Depending on how much flexibility you need, you can choose between the following
106
92
  We believe in using standard protocols as much as possible and it will work nicely, but you need to address most
107
93
  of the issues we listed above in the previous section yourself.
108
94
 
109
- 2. Use our free library (lib/telegenta.min.js) and provide your own frontend
110
- This way you just need to handle events and use javascript methods like call, hangup, startRecord, etc..
111
-
112
- 3. Use our complete embedded phone client with frontend.
113
- We have made a simple phone with frontend, which can be easily included in your own project. Just grab the
114
- index.html, style.css and embedded_phone.js and insert it into your own project.
115
- We named the JS and CSS to try and avoid any potential conflicts.
116
- You only need to provide it with credentials or provisioning token (as in the example above) from you backend and
117
- you are set to start calling.
95
+ 2. Use our free library and provide your own frontend.
96
+ Load `dist/telegenta.min.js` in the browser (global `Telegenta`) or import the package in your
97
+ bundler, then handle events and use javascript methods like call, hangup, startRecord, etc..
98
+ `demos/phone_client.html` shows the whole flow end to end.
118
99
 
119
100
  ## Migrating from 2.x
120
101
 
@@ -45,7 +45,7 @@
45
45
  eventClient = new Telegenta.EventClient(logger);
46
46
 
47
47
  // Connect to event server
48
- eventClient.connect('wss://api.telegenta.com/es/').then(function () {
48
+ eventClient.connect('wss://esvc.telegenta.com/').then(function () {
49
49
  console.info(`Connected to server`);
50
50
  }, function (reason) {
51
51
  console.error(`Unable to connect to event service: ${reason}`);
@@ -53,7 +53,7 @@
53
53
  let eventClient;
54
54
  let subscriptionId = null;
55
55
  const API_URL = 'https://api.telegenta.com/api';
56
- const EVENT_CLIENT_URL = 'wss://api.telegenta.com/es/';
56
+ const EVENT_CLIENT_URL = 'wss://esvc.telegenta.com/';
57
57
 
58
58
  // Start when DOM is ready
59
59
  window.onload = function () {
package/dist/telegenta.js CHANGED
@@ -25,23 +25,23 @@ class LogHandler {
25
25
  }
26
26
 
27
27
  debug(...args) {
28
- this._doLog(LOGLVL$1.DEBUG, args);
28
+ this._doLog(LOGLVL.DEBUG, args);
29
29
  }
30
30
 
31
31
  info(...args) {
32
- this._doLog(LOGLVL$1.INFO, args);
32
+ this._doLog(LOGLVL.INFO, args);
33
33
  }
34
34
 
35
35
  warn(...args) {
36
- this._doLog(LOGLVL$1.WARN, args);
36
+ this._doLog(LOGLVL.WARN, args);
37
37
  }
38
38
 
39
39
  warning(...args) {
40
- this._doLog(LOGLVL$1.WARN, args);
40
+ this._doLog(LOGLVL.WARN, args);
41
41
  }
42
42
 
43
43
  error(...args) {
44
- this._doLog(LOGLVL$1.ERROR, args);
44
+ this._doLog(LOGLVL.ERROR, args);
45
45
  }
46
46
 
47
47
  _doLog(level, args) {
@@ -60,19 +60,19 @@ class LogHandler {
60
60
  this.extHandler(LOGLVLSTR[level], args);
61
61
  } else {
62
62
  switch (level) {
63
- case LOGLVL$1.DEBUG:
63
+ case LOGLVL.DEBUG:
64
64
  console.debug(args);
65
65
  break;
66
66
 
67
- case LOGLVL$1.INFO:
67
+ case LOGLVL.INFO:
68
68
  console.info(args);
69
69
  break;
70
70
 
71
- case LOGLVL$1.WARN:
71
+ case LOGLVL.WARN:
72
72
  console.warn(args);
73
73
  break;
74
74
 
75
- case LOGLVL$1.ERROR:
75
+ case LOGLVL.ERROR:
76
76
  console.error(args);
77
77
  break;
78
78
 
@@ -91,7 +91,7 @@ class LogHandler {
91
91
  * @property {number} INFO 2 Informational messages (default)
92
92
  * @property {number} DEBUG 3 Debug messages
93
93
  */
94
- const LOGLVL$1 = {
94
+ const LOGLVL = {
95
95
  DEBUG: 3,
96
96
  INFO: 2,
97
97
  WARN: 1,
@@ -168,10 +168,11 @@ class InvalidParameter extends Error {
168
168
 
169
169
  /**
170
170
  * Exception raised if trying to perform actions that require active session
171
+ * @property message - Error message
171
172
  */
172
173
  class NoActiveSession extends Error {
173
- constructor() {
174
- super();
174
+ constructor(message) {
175
+ super(message);
175
176
  this.name = 'NoActiveSession';
176
177
  }
177
178
  }
@@ -291,6 +292,43 @@ function browserIsEdge() {
291
292
  return (navigator.userAgent.indexOf("Edge") !== -1)
292
293
  }
293
294
 
295
+ // SIP header names are RFC 3261 tokens
296
+ const HEADER_NAME_RE = /^[A-Za-z0-9\-.!%*_+`'~]+$/;
297
+
298
+ const HTML_ENTITIES = {
299
+ '&': '&amp;',
300
+ '<': '&lt;',
301
+ '>': '&gt;',
302
+ '"': '&quot;',
303
+ "'": '&#39;',
304
+ '\r': '&#13;',
305
+ '\n': '&#10;',
306
+ };
307
+
308
+ // Build custom SIP header lines from a { name: value } map.
309
+ // Names must be tokens - a bad one is always a programming error, so it throws.
310
+ // Values are HTML encoded rather than rejected: that strips the CR/LF which
311
+ // would otherwise let a value inject further headers, while still letting
312
+ // punctuation through.
313
+ function buildCustomHeaders(setHeader) {
314
+ if (!setHeader) {
315
+ return [];
316
+ }
317
+
318
+ if (typeof setHeader !== 'object' || Array.isArray(setHeader)) {
319
+ throw new TypeError(`Invalid setHeader argument: expected an object, got ${typeof setHeader}`);
320
+ }
321
+
322
+ return Object.entries(setHeader).map(([name, value]) => {
323
+ if (!HEADER_NAME_RE.test(name)) {
324
+ throw new TypeError(`Invalid SIP header name: "${name}"`);
325
+ }
326
+ // Single pass, so an encoded '&' is not encoded again.
327
+ const encoded = String(value).replace(/[&<>"'\r\n]/g, (c) => HTML_ENTITIES[c]);
328
+ return `${name}: ${encoded}`;
329
+ });
330
+ }
331
+
294
332
  // Request timeout after 10 sec
295
333
  const REQUEST_TIMEOUT = 10000;
296
334
 
@@ -348,7 +386,7 @@ class Request {
348
386
  class Requestor extends EventEmitter {
349
387
  constructor(logger = null) {
350
388
  super();
351
- this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
389
+ this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
352
390
  this._pendingRequests = [];
353
391
  }
354
392
 
@@ -363,13 +401,13 @@ class Requestor extends EventEmitter {
363
401
  let msg;
364
402
  try {
365
403
  msg = JSON.parse(message);
366
- } catch (err) {
404
+ } catch {
367
405
  this.log.warning("Unable to parse message: " + message);
368
406
  return;
369
407
  }
370
408
 
371
409
  // Validate msg fields
372
- if (!msg.hasOwnProperty("type") || !msg.hasOwnProperty("id")) {
410
+ if (!Object.hasOwn(msg, "type") || !Object.hasOwn(msg, "id")) {
373
411
  this.log.warning(`Invalid message received: ${msg} - missing type or id`);
374
412
  return;
375
413
  }
@@ -522,7 +560,7 @@ class Requestor extends EventEmitter {
522
560
  // Keep alive in seconds
523
561
  const PING_INTERVAL = 50;
524
562
 
525
- const ES_DEFAULT_SERVER_URL = 'wss://api.telegenta.com/es/';
563
+ const ES_DEFAULT_SERVER_URL = 'wss://esvc.telegenta.com/';
526
564
 
527
565
  // Starting retry interval
528
566
  const RETRY_INTERVAL_START = 1 + Math.random();
@@ -620,7 +658,7 @@ class EventClient {
620
658
  throw new InvalidParameter('You must specify both event spec and callback function');
621
659
  }
622
660
 
623
- if (!eventSpecification.hasOwnProperty('event_name')) {
661
+ if (!Object.hasOwn(eventSpecification, 'event_name')) {
624
662
  throw new InvalidParameter('Your event specification must contain event name');
625
663
  }
626
664
 
@@ -1504,7 +1542,7 @@ function camelcaseKeys(input, options) {
1504
1542
 
1505
1543
  const options = {
1506
1544
  wssServer: 'wss://api.telegenta.com/sip/',
1507
- eventServer: 'wss://api.telegenta.com/es/',
1545
+ eventServer: 'wss://esvc.telegenta.com/',
1508
1546
  provisionToken: null,
1509
1547
  authorizationUsername: null,
1510
1548
  password: null,
@@ -1625,10 +1663,10 @@ class Phone extends EventEmitter {
1625
1663
  this._oscillator2 = null;
1626
1664
 
1627
1665
  // Set default logger
1628
- this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
1666
+ this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
1629
1667
 
1630
1668
  // Handle jssip debug logs
1631
- if (this.log.level === LOGLVL$1.DEBUG) {
1669
+ if (this.log.level === LOGLVL.DEBUG) {
1632
1670
  JsSIP.debug.disable('JsSIP:*');
1633
1671
  } else {
1634
1672
  JsSIP.debug.disable('JsSIP:*');
@@ -1703,10 +1741,10 @@ class Phone extends EventEmitter {
1703
1741
 
1704
1742
  // Unpack provisioning token if specified
1705
1743
  if (this._options.provisionToken) {
1706
- let pt = null;
1744
+ let pt;
1707
1745
  try {
1708
1746
  pt = JSON.parse(atob(this._options.provisionToken));
1709
- } catch (err) {
1747
+ } catch {
1710
1748
  throw new InvalidOptions('provisionToken', 'Invalid token');
1711
1749
  }
1712
1750
 
@@ -1782,7 +1820,7 @@ class Phone extends EventEmitter {
1782
1820
  );
1783
1821
  }
1784
1822
 
1785
- let wsSocket = null;
1823
+ let wsSocket;
1786
1824
  try {
1787
1825
  wsSocket = new JsSIP.WebSocketInterface(this._options.wssServer);
1788
1826
  } catch (err) {
@@ -1894,7 +1932,7 @@ class Phone extends EventEmitter {
1894
1932
  try {
1895
1933
  this._eventClient.disconnect();
1896
1934
  delete this._eventClient;
1897
- } catch (e) {
1935
+ } catch {
1898
1936
  this.log.warning('Unable to disconnect event client');
1899
1937
  }
1900
1938
 
@@ -1913,9 +1951,11 @@ class Phone extends EventEmitter {
1913
1951
  * @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
1952
  * @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
1953
  * @param {string} [params.metaInfo] Add meta info to call, which will be available in server events and inside call complete webhook (max 256 chars)
1954
+ * @param {object} [params.setHeader] Custom SIP headers to add to the INVITE, as {name: value}.
1916
1955
  * @return {Session} object for call
1917
1956
  * @throws NotReady - If phone is not ready to call
1918
1957
  * @throws NotAllowed - If there are calls currently connecting
1958
+ * @throws InvalidParameter - If setHeader contains an invalid header name or container
1919
1959
  *
1920
1960
  */
1921
1961
  call(number, params = {}) {
@@ -1940,6 +1980,7 @@ class Phone extends EventEmitter {
1940
1980
  explicitCallerId: null,
1941
1981
  explicitShortCallerId: null,
1942
1982
  metaInfo: null,
1983
+ setHeader: null,
1943
1984
  _isListenCall: false,
1944
1985
  },
1945
1986
  params,
@@ -1982,7 +2023,7 @@ class Phone extends EventEmitter {
1982
2023
  */
1983
2024
  getSessionByUUID(uuid) {
1984
2025
  for (let key in this._activeSessions) {
1985
- if (this._activeSessions.hasOwnProperty(key)) {
2026
+ if (Object.hasOwn(this._activeSessions, key)) {
1986
2027
  if (this._activeSessions[key].uuid === uuid) {
1987
2028
  return this._activeSessions[key];
1988
2029
  }
@@ -2007,7 +2048,7 @@ class Phone extends EventEmitter {
2007
2048
  get sessions() {
2008
2049
  let s = [];
2009
2050
  for (let key in this._activeSessions) {
2010
- if (this._activeSessions.hasOwnProperty(key)) {
2051
+ if (Object.hasOwn(this._activeSessions, key)) {
2011
2052
  s.push(this._activeSessions[key]);
2012
2053
  }
2013
2054
  }
@@ -2172,7 +2213,7 @@ class Phone extends EventEmitter {
2172
2213
 
2173
2214
  // Iterate through all active calls and hold them
2174
2215
  for (let key in this._activeSessions) {
2175
- if (this._activeSessions.hasOwnProperty(key)) {
2216
+ if (Object.hasOwn(this._activeSessions, key)) {
2176
2217
  let session = this._activeSessions[key];
2177
2218
  if (session.state === 'ACTIVE') {
2178
2219
  session.hold();
@@ -2246,7 +2287,7 @@ class Phone extends EventEmitter {
2246
2287
  // Return true if there are calls currently connecting
2247
2288
  _checkForConnectingCalls() {
2248
2289
  for (let key in this._activeSessions) {
2249
- if (this._activeSessions.hasOwnProperty(key)) {
2290
+ if (Object.hasOwn(this._activeSessions, key)) {
2250
2291
  let session = this._activeSessions[key];
2251
2292
  if (
2252
2293
  session.state === 'INITIALIZING' ||
@@ -2495,6 +2536,23 @@ class Session extends EventEmitter {
2495
2536
  }
2496
2537
  }
2497
2538
 
2539
+ // Custom headers last, so they cannot displace the built-in ones.
2540
+ // Must be before _UA.call() below: jssip clones extraHeaders synchronously
2541
+ // inside connect(), so anything pushed after that call never reaches the INVITE.
2542
+ let customHeaders;
2543
+ try {
2544
+ customHeaders = buildCustomHeaders(callParams.setHeader);
2545
+ } catch (error) {
2546
+ if (error instanceof TypeError) {
2547
+ throw new InvalidParameter(error.message);
2548
+ }
2549
+ throw error;
2550
+ }
2551
+ for (const header of customHeaders) {
2552
+ this.log.debug('Adding custom header: ' + header);
2553
+ options.extraHeaders.push(header);
2554
+ }
2555
+
2498
2556
  // Prepare invite uri
2499
2557
  const uri = 'sip:' + number + '@telegenta.com';
2500
2558
 
@@ -2613,7 +2671,7 @@ class Session extends EventEmitter {
2613
2671
  self.log.info(`Answer call timeout after ${self._answerCallTimeout} seconds`);
2614
2672
  try {
2615
2673
  self.hangup();
2616
- } catch (e) {
2674
+ } catch {
2617
2675
  self.log.info('No active session, ignoring answer call timeout');
2618
2676
  }
2619
2677
  }, self._answerCallTimeout * 1000);
@@ -2867,6 +2925,30 @@ class Session extends EventEmitter {
2867
2925
  }
2868
2926
  }
2869
2927
 
2928
+ /**
2929
+ * Raw WebRTC statistics for this session's peer connection.
2930
+ *
2931
+ * Returns the browser's own `RTCStatsReport` untouched, so callers can read
2932
+ * any statistic the browser exposes - RTP packet and byte counters, the
2933
+ * DTLS/ICE transport state, codec and candidate-pair details.
2934
+ *
2935
+ * @returns {Promise<RTCStatsReport>} The peer connection's statistics
2936
+ * @throws NoActiveSession - If the session has no established peer connection
2937
+ * (thrown synchronously, before the promise is created)
2938
+ *
2939
+ * @example
2940
+ * const report = await session.getRTCStats();
2941
+ * for (const stat of report.values()) {
2942
+ * if (stat.type === 'inbound-rtp') console.log(stat.packetsReceived);
2943
+ * }
2944
+ */
2945
+ getRTCStats() {
2946
+ if (!this._webRTCSession || !this._webRTCSession.connection) {
2947
+ throw new NoActiveSession('no peer connection for this session');
2948
+ }
2949
+ return this._webRTCSession.connection.getStats();
2950
+ }
2951
+
2870
2952
  // RECORDING
2871
2953
  /**
2872
2954
  * Start recording
@@ -3660,4 +3742,4 @@ class Session extends EventEmitter {
3660
3742
  }
3661
3743
  }
3662
3744
 
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 };
3745
+ export { EventClient, EventServiceNotConnected, InvalidMediaDevice, InvalidOptions, InvalidParameter, LOGLVL, LogHandler, MediaNotPrepared, NoActiveSession, NotAllowed, NotReady, Phone, Request, Requestor, Session, UnableToConnect, browserIsChrome, browserIsEdge, browserIsFirefox, browserIsOpera, browserIsSafari, buildCustomHeaders, compareObjects, createDurationString, createUUID };