@telegenta/webclient 3.1.2 → 3.2.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.
package/README.md CHANGED
@@ -4,7 +4,10 @@ A browser client library for using Telegenta phone services
4
4
 
5
5
  # Detailed documentation
6
6
 
7
- Please go to: https://www.telegenta.com/docs/index.html
7
+ Please go to: https://www.telegenta.com/docs/webclient/latest/index.html
8
+
9
+ Documentation for a specific version is at `https://www.telegenta.com/docs/webclient/<version>/index.html`,
10
+ e.g. https://www.telegenta.com/docs/webclient/3.2.0/index.html
8
11
 
9
12
  ## Why this library?
10
13
 
@@ -142,6 +145,33 @@ If you are migrating from 1.7.x release into 2.x, some code breaking changes nee
142
145
 
143
146
  This library uses the JsSIP library (www.jssip.net) created by José Luis Milàn, Iñaki Baz Castillo and Saúl Ibarra Corretgé
144
147
 
148
+ ## Changelog
149
+
150
+ 3.2.0
151
+ - Phone.setCallToken(token): replace the call token without restarting (tokens expire after 30 min).
152
+ With useCallDataInfoEvent, the callDataInfo subscription keeps the token from start(); call
153
+ start() again to move it to the new token.
154
+ - Phone.prepareMedia({ deviceId }) and Phone.setInputDevice(deviceId): microphone selection
155
+ - Phone.prepareMedia() without a deviceId resets a microphone previously chosen with setInputDevice()
156
+ - Phone.setOutputDevice(deviceId) and Phone.outputDeviceSelectionSupported: speaker selection
157
+ - prepareMedia() releases the microphone after the permission probe
158
+ - Phone.resumeAudio(): resumes the signal-tone AudioContext; call it from a user-gesture handler,
159
+ since a phone started on page load gets a "suspended" context and plays no tones. Tones also
160
+ try to resume it themselves. One AudioContext per Phone is now reused across start()/stop()
161
+ (previously every start() created a new one that was never closed)
162
+ - prepareMedia() now rejects instead of throwing synchronously, and a failed request no longer
163
+ changes the selected microphone or media-permission state unless the browser actually denied
164
+ permission (NotAllowedError/SecurityError)
165
+ - start() no longer mutates the shared default options between Phone instances
166
+ - stop() now always marks the phone stopped
167
+ - Phone.register(): re-sends REGISTER without restarting the phone or dropping active calls
168
+ (throws NotReady if not started) - for recovering a REGISTER refresh that failed for a reason
169
+ other than bad credentials, which JsSIP does not retry on its own
170
+ - connectionStateChange now carries `cause` and `statusCode` alongside `state` when
171
+ state is REGISTRATION_FAILED (from JsSIP's registrationFailed event), so callers can tell a
172
+ transient network failure (JsSIP retries on its own) apart from an authentication/authorization
173
+ failure (401/403) that needs a fresh call session
174
+
145
175
  #### License
146
176
 
147
177
  This software is provided for free together with our service
package/dist/telegenta.js CHANGED
@@ -1643,6 +1643,9 @@ class Phone extends EventEmitter {
1643
1643
  this._eventClient = null;
1644
1644
  this._options = null;
1645
1645
 
1646
+ // getUserMedia audio constraint: true (default device) or { deviceId: { exact } }
1647
+ this._audioConstraints = true;
1648
+
1646
1649
  // List of active sessions
1647
1650
  this._sessionCounter = 0;
1648
1651
  this._activeSessions = {};
@@ -1658,6 +1661,7 @@ class Phone extends EventEmitter {
1658
1661
  this._repeatToneActive = false;
1659
1662
  this._repeatToneSequence = [];
1660
1663
  this._repeatToneSequencePos = 0;
1664
+ this._toneContext = null;
1661
1665
  this._toneGainProcessor = null;
1662
1666
  this._oscillator1 = null;
1663
1667
  this._oscillator2 = null;
@@ -1677,29 +1681,83 @@ class Phone extends EventEmitter {
1677
1681
  * Prepare media and get media permission from browser
1678
1682
  * This must be done prior to using the phone or getting device lists.
1679
1683
  *
1680
- * @return A promise which will be resolved when user accepts access to media devices
1684
+ * On failure the previously selected input device (see {@link setInputDevice}) and the
1685
+ * current media-permission state are left untouched, unless the browser reports the
1686
+ * request was actually denied (NotAllowedError / SecurityError), in which case
1687
+ * hasMediaPermission/hasMediaPermissionError are updated to reflect that.
1681
1688
  *
1682
- */
1683
- prepareMedia() {
1689
+ * @param {object} [mediaOptions]
1690
+ * @param {string} [mediaOptions.deviceId] Microphone deviceId (from enumerateDevices); omit or leave
1691
+ * out to select the browser default device, which also resets a device previously chosen with
1692
+ * {@link setInputDevice}
1693
+ * @return {Promise} Resolved when user accepts access to media devices; rejected (never thrown
1694
+ * synchronously) otherwise, e.g. when the browser denies permission or has no mediaDevices API
1695
+ */
1696
+ async prepareMedia(mediaOptions = {}) {
1684
1697
  this.log.info('Get media permission');
1685
- const self = this;
1686
- const constraints = { audio: true, video: false };
1687
- return new Promise(function (resolve, reject) {
1688
- navigator.mediaDevices.getUserMedia(constraints).then(
1689
- () => {
1690
- self._mediaPermission = true;
1691
- self._mediaPermissionError = false;
1692
- self.log.info('Media permission granted');
1693
- resolve();
1694
- },
1695
- (err) => {
1696
- self._mediaPermission = false;
1697
- self._mediaPermissionError = true;
1698
- self.log.error('Denied media permission: ' + err);
1699
- reject(err);
1700
- },
1701
- );
1702
- });
1698
+ const deviceId = (mediaOptions || {}).deviceId || null;
1699
+ const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true;
1700
+ const constraints = { audio: audioConstraints, video: false };
1701
+
1702
+ try {
1703
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
1704
+ // Only the permission is needed here; release the device again.
1705
+ stream.getTracks().forEach((track) => track.stop());
1706
+ this.setInputDevice(deviceId);
1707
+ this._mediaPermission = true;
1708
+ this._mediaPermissionError = false;
1709
+ this.log.info('Media permission granted');
1710
+ } catch (err) {
1711
+ if (err && (err.name === 'NotAllowedError' || err.name === 'SecurityError')) {
1712
+ this._mediaPermission = false;
1713
+ this._mediaPermissionError = true;
1714
+ this.log.error('Denied media permission: ' + err);
1715
+ } else {
1716
+ this.log.error('Unable to get requested media device: ' + err);
1717
+ }
1718
+ throw err;
1719
+ }
1720
+ }
1721
+
1722
+ /**
1723
+ * Select the microphone used for subsequent calls and answers.
1724
+ * @param {string|null} deviceId Microphone deviceId, or null for the browser default
1725
+ */
1726
+ setInputDevice(deviceId) {
1727
+ this._audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true;
1728
+ }
1729
+
1730
+ /**
1731
+ * Current getUserMedia audio constraint (true = default device)
1732
+ */
1733
+ get audioConstraints() {
1734
+ return this._audioConstraints;
1735
+ }
1736
+
1737
+ /**
1738
+ * True when the browser can route audio to a chosen output device (HTMLMediaElement.setSinkId)
1739
+ */
1740
+ get outputDeviceSelectionSupported() {
1741
+ return typeof this._remoteAudioElement.setSinkId === 'function';
1742
+ }
1743
+
1744
+ /**
1745
+ * Route call audio and signal tones to an output device.
1746
+ * @param {string|null} deviceId Output deviceId (from enumerateDevices), or null for the default
1747
+ * @return {Promise<boolean>} false when the browser does not support output selection
1748
+ * @throws Rejects if the browser's HTMLMediaElement.setSinkId() call rejects, e.g. for an
1749
+ * unknown deviceId or one the browser is not allowed to use (NotAllowedError)
1750
+ */
1751
+ async setOutputDevice(deviceId) {
1752
+ if (!this.outputDeviceSelectionSupported) {
1753
+ return false;
1754
+ }
1755
+ const sinkId = deviceId || '';
1756
+ await Promise.all([
1757
+ this._remoteAudioElement.setSinkId(sinkId),
1758
+ this._toneAudioElement.setSinkId(sinkId),
1759
+ ]);
1760
+ return true;
1703
1761
  }
1704
1762
 
1705
1763
  /**
@@ -1735,7 +1793,7 @@ class Phone extends EventEmitter {
1735
1793
  }
1736
1794
 
1737
1795
  // Apply options
1738
- this._options = Object.assign(options, phoneOptions);
1796
+ this._options = Object.assign({}, options, phoneOptions);
1739
1797
 
1740
1798
  this._initToneGenerator();
1741
1799
 
@@ -1898,8 +1956,11 @@ class Phone extends EventEmitter {
1898
1956
  self._setConnectionState('UNREGISTERED');
1899
1957
  });
1900
1958
 
1901
- this._UA.on('registrationFailed', function (/* data */) {
1902
- self._setConnectionState('REGISTRATION_FAILED');
1959
+ this._UA.on('registrationFailed', function (data) {
1960
+ self._setConnectionState('REGISTRATION_FAILED', {
1961
+ cause: data?.cause,
1962
+ statusCode: data?.response?.status_code,
1963
+ });
1903
1964
  });
1904
1965
 
1905
1966
  this._UA.start();
@@ -1924,6 +1985,11 @@ class Phone extends EventEmitter {
1924
1985
  this._UA.stop();
1925
1986
  delete this._UA;
1926
1987
 
1988
+ // Always mark the phone stopped from here on, even if there is no event client to
1989
+ // disconnect below - otherwise isStarted stays true and a later start() would try to
1990
+ // stop() this same (already torn down) instance again.
1991
+ this._isStarted = false;
1992
+
1927
1993
  // Disconnect event client if needed
1928
1994
  if (!this._eventClient) {
1929
1995
  this.log.debug('Event client not running, not stopping');
@@ -1935,11 +2001,23 @@ class Phone extends EventEmitter {
1935
2001
  } catch {
1936
2002
  this.log.warning('Unable to disconnect event client');
1937
2003
  }
1938
-
1939
- this._isStarted = false;
1940
2004
  this.log.info('Phone client successfully stopped');
1941
2005
  }
1942
2006
 
2007
+ /**
2008
+ * Re-send REGISTER without tearing down the websocket connection or any active calls.
2009
+ * Use this to recover from a REGISTER refresh that failed for a reason other than bad
2010
+ * credentials (e.g. a timeout or a 5xx from the registrar) while the transport itself stayed
2011
+ * up - JsSIP does not retry those on its own. Safe to call during an active call.
2012
+ * @throws NotReady - If phone is not started
2013
+ */
2014
+ register() {
2015
+ if (!this._isStarted) {
2016
+ throw new NotReady('Phone is not started');
2017
+ }
2018
+ this._UA.register();
2019
+ }
2020
+
1943
2021
  /**
1944
2022
  * Start a new call
1945
2023
  * If you have other active calls they will be set on hold
@@ -2041,6 +2119,27 @@ class Phone extends EventEmitter {
2041
2119
  return this._options;
2042
2120
  }
2043
2121
 
2122
+ /**
2123
+ * Replace the call token used by subsequent calls and answers.
2124
+ * Call tokens expire (30 min); renew via the API and pass the new token here.
2125
+ *
2126
+ * With useCallDataInfoEvent, the callDataInfo subscription keeps the token from start();
2127
+ * call start() again to move it to the new token.
2128
+ *
2129
+ * @param {string} token New call token
2130
+ * @throws NotReady - If phone is not started
2131
+ * @throws InvalidOptions - If token is empty
2132
+ */
2133
+ setCallToken(token) {
2134
+ if (!this._isStarted) {
2135
+ throw new NotReady('Phone is not started');
2136
+ }
2137
+ if (!token) {
2138
+ throw new InvalidOptions('callToken', 'Missing call token');
2139
+ }
2140
+ this._options.callToken = token;
2141
+ }
2142
+
2044
2143
  /**
2045
2144
  * Return list of current sessions
2046
2145
  * @returns [Session]
@@ -2106,6 +2205,7 @@ class Phone extends EventEmitter {
2106
2205
  */
2107
2206
  playTone(duration, freq1, freq2 = 0, callback = null) {
2108
2207
  this.log.debug(`Playing tone ${freq1} / ${freq2} for ${duration} ms`);
2208
+ this._resumeToneContext();
2109
2209
 
2110
2210
  this._oscillator1.frequency.value = freq1;
2111
2211
  this._oscillator2.frequency.value = freq2;
@@ -2172,8 +2272,35 @@ class Phone extends EventEmitter {
2172
2272
  }
2173
2273
  }
2174
2274
 
2175
- // Initialize tone generator
2275
+ /**
2276
+ * Resume the Web Audio context behind signal tones (ringing, ringback, busy, DTMF feedback).
2277
+ * Browsers create an AudioContext "suspended" when it is not created during a user gesture
2278
+ * (e.g. a phone started on page load); call this from a user-gesture handler (click, keydown)
2279
+ * so tones are audible. Safe to call before {@link start} and any number of times.
2280
+ * @return {Promise<boolean>} false when the browser has no Web Audio API or resuming failed
2281
+ */
2282
+ async resumeAudio() {
2283
+ try {
2284
+ this._initToneGenerator();
2285
+ } catch {
2286
+ return false;
2287
+ }
2288
+ try {
2289
+ await this._toneContext.resume();
2290
+ return this._toneContext.state === 'running';
2291
+ } catch (err) {
2292
+ this.log.warning('Unable to resume audio context: ' + err);
2293
+ return false;
2294
+ }
2295
+ }
2296
+
2297
+ // Initialize tone generator. One AudioContext per Phone, reused across start()/stop() -
2298
+ // browsers cap the number of live contexts and a new one per start() would leak.
2176
2299
  _initToneGenerator() {
2300
+ if (this._toneContext && this._toneContext.state !== 'closed') {
2301
+ return;
2302
+ }
2303
+
2177
2304
  // Create audio context
2178
2305
  const AudioContext = window.AudioContext || window.webkitAudioContext || false;
2179
2306
  if (!AudioContext) {
@@ -2181,6 +2308,7 @@ class Phone extends EventEmitter {
2181
2308
  }
2182
2309
 
2183
2310
  const ctx = new AudioContext();
2311
+ this._toneContext = ctx;
2184
2312
  this._toneStreamNode = ctx.createMediaStreamDestination();
2185
2313
  this._toneAudioElement.srcObject = this._toneStreamNode.stream;
2186
2314
 
@@ -2200,6 +2328,14 @@ class Phone extends EventEmitter {
2200
2328
  this._oscillator2.start();
2201
2329
  }
2202
2330
 
2331
+ // Best effort: a context created outside a user gesture is suspended until resumed.
2332
+ _resumeToneContext() {
2333
+ const ctx = this._toneContext;
2334
+ if (ctx && ctx.state === 'suspended' && typeof ctx.resume === 'function') {
2335
+ ctx.resume().catch((err) => this.log.debug('Audio context resume refused: ' + err));
2336
+ }
2337
+ }
2338
+
2203
2339
  /**
2204
2340
  * Hold all active calls
2205
2341
  * @throws NotAllowed If there are connection calls
@@ -2312,17 +2448,23 @@ class Phone extends EventEmitter {
2312
2448
  * @property {string} REGISTRATION_FAILED Registration failed
2313
2449
  */
2314
2450
  // Set connection state and emit event
2315
- _setConnectionState(state) {
2451
+ _setConnectionState(state, extra = {}) {
2316
2452
  /**
2317
2453
  * @event connectionStateChange
2318
2454
  * @type {object}
2319
2455
  * @param {CONNECTION_STATE} state - The current connection state
2456
+ * @param {string} [cause] - For REGISTRATION_FAILED only: JsSIP's registrationFailed
2457
+ * cause, e.g. 'Connection Error', 'Request Timeout' (network/transport issues, JsSIP
2458
+ * will keep retrying on its own), 'Authentication Error' (401), or 'Rejected' (403 -
2459
+ * account disabled or credentials rejected by the registrar)
2460
+ * @param {number} [statusCode] - For REGISTRATION_FAILED only: the SIP status code of
2461
+ * the failed REGISTER response, when JsSIP received one (e.g. 401, 403)
2320
2462
  */
2321
2463
 
2322
2464
  this.log.info('Connection state: ' + state);
2323
2465
  this._connectionState = state;
2324
2466
  try {
2325
- this.emit('connectionStateChange', { state: state });
2467
+ this.emit('connectionStateChange', { state: state, ...extra });
2326
2468
  } catch (e) {
2327
2469
  this.log.error('Exception in connectionStateChange event: ' + e);
2328
2470
  }
@@ -2370,18 +2512,15 @@ class Phone extends EventEmitter {
2370
2512
  * @param {string} effectiveCidNameStr - Phone number name used as a-number for call
2371
2513
  * @param {boolean} callPrivacy - Call privacy used for call (number hidden)
2372
2514
  * @param {number} hangupCauseQ850 - Q.850 hangup cause code
2373
- * @param {Object[]} recordings - List of recordings done for this call
2374
- * @param {number} recordings[].startTime - Time recording was started
2375
- * @param {number} recordings[].length - Recording length in milliseconds
2376
- * @param {number} recordings[].size - Recording size (bytes)
2377
- * @param {number} recordings[].urlPlayer - URL for recording with player
2378
- * @param {number} recordings[].url - URL for recording without player
2379
- * @param {number} recordings[].retentionDate - Date recording will be automatically deleted
2380
2515
  */
2381
2516
 
2382
2517
  let e;
2383
2518
  e = camelcaseKeys(eventData);
2384
- e.recordings = camelcaseKeys(eventData.recordings);
2519
+ // The call data event no longer carries recordings. Uploads are
2520
+ // asynchronous, so the array was almost always empty by the time the CDR
2521
+ // was processed, and the event cannot be re-fired once a recording lands
2522
+ // because call_token is never persisted on the CDR. A dedicated
2523
+ // "recording ready" event will replace it.
2385
2524
 
2386
2525
  // Event all duration fields are in seconds, convert to millisecs
2387
2526
  e.duration = e.duration * 1000;
@@ -2474,7 +2613,7 @@ class Session extends EventEmitter {
2474
2613
  this.destinationNumber = number;
2475
2614
 
2476
2615
  let options = {
2477
- mediaConstraints: { audio: true, video: false },
2616
+ mediaConstraints: { audio: this._phone.audioConstraints, video: false },
2478
2617
  pcConfig: {
2479
2618
  iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
2480
2619
  },
@@ -2828,7 +2967,7 @@ class Session extends EventEmitter {
2828
2967
  */
2829
2968
  answer(metaInfo = null) {
2830
2969
  let options = {
2831
- mediaConstraints: { audio: true, video: false },
2970
+ mediaConstraints: { audio: this._phone.audioConstraints, video: false },
2832
2971
  pcConfig: {
2833
2972
  iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
2834
2973
  },