@telegenta/webclient 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -53
- package/dist/telegenta.js +204 -65
- package/dist/telegenta.min.js +1 -1
- package/docs/Phone.html +1 -1
- package/docs/Session.html +2 -2
- package/docs/data/search.json +1 -1
- package/docs/event-client.js.html +4 -4
- package/docs/global.html +3 -3
- package/docs/phone-client.js.html +190 -51
- package/docs/requestor.js.html +6 -6
- package/package.json +12 -3
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
|
-
|
|
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
|
-
|
|
47
|
+
#### Example: phone with a provisioning token
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
var USERNAME='your username';
|
|
53
|
-
var PASSWORD='your password';
|
|
54
|
-
|
|
55
|
-
window.onload = function () {
|
|
56
|
-
...
|
|
57
|
-
```
|
|
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
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
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:
|
|
64
57
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
For this example we need a backend service and we provide a simple example in PHP using the built in Webserver.
|
|
58
|
+
```
|
|
59
|
+
const opts = {
|
|
60
|
+
// Enable event client
|
|
61
|
+
connectEventService: true,
|
|
70
62
|
|
|
71
|
-
|
|
63
|
+
// Subscribe to detailed information about call after processing
|
|
64
|
+
useCallDataInfoEvent: true,
|
|
72
65
|
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
// Enable incoming calls (you must set a phone number to call the account also)
|
|
67
|
+
register: true,
|
|
75
68
|
|
|
76
|
-
|
|
69
|
+
// Configure client with provision token
|
|
70
|
+
provisionToken: data.provision_token,
|
|
71
|
+
};
|
|
77
72
|
|
|
73
|
+
phoneClient.start(opts);
|
|
78
74
|
```
|
|
79
|
-
// Your API Token
|
|
80
|
-
$api_auth_token = '<your api token>';
|
|
81
75
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
$flex_identification = 'test';
|
|
85
|
-
...
|
|
86
|
-
```
|
|
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.
|
|
87
78
|
|
|
88
|
-
|
|
79
|
+
The remaining demos cover narrower cases:
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
|
@@ -161,6 +142,33 @@ If you are migrating from 1.7.x release into 2.x, some code breaking changes nee
|
|
|
161
142
|
|
|
162
143
|
This library uses the JsSIP library (www.jssip.net) created by José Luis Milàn, Iñaki Baz Castillo and Saúl Ibarra Corretgé
|
|
163
144
|
|
|
145
|
+
## Changelog
|
|
146
|
+
|
|
147
|
+
3.2.0
|
|
148
|
+
- Phone.setCallToken(token): replace the call token without restarting (tokens expire after 30 min).
|
|
149
|
+
With useCallDataInfoEvent, the callDataInfo subscription keeps the token from start(); call
|
|
150
|
+
start() again to move it to the new token.
|
|
151
|
+
- Phone.prepareMedia({ deviceId }) and Phone.setInputDevice(deviceId): microphone selection
|
|
152
|
+
- Phone.prepareMedia() without a deviceId resets a microphone previously chosen with setInputDevice()
|
|
153
|
+
- Phone.setOutputDevice(deviceId) and Phone.outputDeviceSelectionSupported: speaker selection
|
|
154
|
+
- prepareMedia() releases the microphone after the permission probe
|
|
155
|
+
- Phone.resumeAudio(): resumes the signal-tone AudioContext; call it from a user-gesture handler,
|
|
156
|
+
since a phone started on page load gets a "suspended" context and plays no tones. Tones also
|
|
157
|
+
try to resume it themselves. One AudioContext per Phone is now reused across start()/stop()
|
|
158
|
+
(previously every start() created a new one that was never closed)
|
|
159
|
+
- prepareMedia() now rejects instead of throwing synchronously, and a failed request no longer
|
|
160
|
+
changes the selected microphone or media-permission state unless the browser actually denied
|
|
161
|
+
permission (NotAllowedError/SecurityError)
|
|
162
|
+
- start() no longer mutates the shared default options between Phone instances
|
|
163
|
+
- stop() now always marks the phone stopped
|
|
164
|
+
- Phone.register(): re-sends REGISTER without restarting the phone or dropping active calls
|
|
165
|
+
(throws NotReady if not started) - for recovering a REGISTER refresh that failed for a reason
|
|
166
|
+
other than bad credentials, which JsSIP does not retry on its own
|
|
167
|
+
- connectionStateChange now carries `cause` and `statusCode` alongside `state` when
|
|
168
|
+
state is REGISTRATION_FAILED (from JsSIP's registrationFailed event), so callers can tell a
|
|
169
|
+
transient network failure (JsSIP retries on its own) apart from an authentication/authorization
|
|
170
|
+
failure (401/403) that needs a fresh call session
|
|
171
|
+
|
|
164
172
|
#### License
|
|
165
173
|
|
|
166
174
|
This software is provided for free together with our service
|
package/dist/telegenta.js
CHANGED
|
@@ -25,23 +25,23 @@ class LogHandler {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
debug(...args) {
|
|
28
|
-
this._doLog(LOGLVL
|
|
28
|
+
this._doLog(LOGLVL.DEBUG, args);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
info(...args) {
|
|
32
|
-
this._doLog(LOGLVL
|
|
32
|
+
this._doLog(LOGLVL.INFO, args);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
warn(...args) {
|
|
36
|
-
this._doLog(LOGLVL
|
|
36
|
+
this._doLog(LOGLVL.WARN, args);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
warning(...args) {
|
|
40
|
-
this._doLog(LOGLVL
|
|
40
|
+
this._doLog(LOGLVL.WARN, args);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
error(...args) {
|
|
44
|
-
this._doLog(LOGLVL
|
|
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
|
|
63
|
+
case LOGLVL.DEBUG:
|
|
64
64
|
console.debug(args);
|
|
65
65
|
break;
|
|
66
66
|
|
|
67
|
-
case LOGLVL
|
|
67
|
+
case LOGLVL.INFO:
|
|
68
68
|
console.info(args);
|
|
69
69
|
break;
|
|
70
70
|
|
|
71
|
-
case LOGLVL
|
|
71
|
+
case LOGLVL.WARN:
|
|
72
72
|
console.warn(args);
|
|
73
73
|
break;
|
|
74
74
|
|
|
75
|
-
case LOGLVL
|
|
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
|
|
94
|
+
const LOGLVL = {
|
|
95
95
|
DEBUG: 3,
|
|
96
96
|
INFO: 2,
|
|
97
97
|
WARN: 1,
|
|
@@ -386,7 +386,7 @@ class Request {
|
|
|
386
386
|
class Requestor extends EventEmitter {
|
|
387
387
|
constructor(logger = null) {
|
|
388
388
|
super();
|
|
389
|
-
this.log = logger ? logger : new LogHandler(LOGLVL
|
|
389
|
+
this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
|
|
390
390
|
this._pendingRequests = [];
|
|
391
391
|
}
|
|
392
392
|
|
|
@@ -401,13 +401,13 @@ class Requestor extends EventEmitter {
|
|
|
401
401
|
let msg;
|
|
402
402
|
try {
|
|
403
403
|
msg = JSON.parse(message);
|
|
404
|
-
} catch
|
|
404
|
+
} catch {
|
|
405
405
|
this.log.warning("Unable to parse message: " + message);
|
|
406
406
|
return;
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
// Validate msg fields
|
|
410
|
-
if (!
|
|
410
|
+
if (!Object.hasOwn(msg, "type") || !Object.hasOwn(msg, "id")) {
|
|
411
411
|
this.log.warning(`Invalid message received: ${msg} - missing type or id`);
|
|
412
412
|
return;
|
|
413
413
|
}
|
|
@@ -658,7 +658,7 @@ class EventClient {
|
|
|
658
658
|
throw new InvalidParameter('You must specify both event spec and callback function');
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
-
if (!
|
|
661
|
+
if (!Object.hasOwn(eventSpecification, 'event_name')) {
|
|
662
662
|
throw new InvalidParameter('Your event specification must contain event name');
|
|
663
663
|
}
|
|
664
664
|
|
|
@@ -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,15 +1661,16 @@ 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;
|
|
1664
1668
|
|
|
1665
1669
|
// Set default logger
|
|
1666
|
-
this.log = logger ? logger : new LogHandler(LOGLVL
|
|
1670
|
+
this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
|
|
1667
1671
|
|
|
1668
1672
|
// Handle jssip debug logs
|
|
1669
|
-
if (this.log.level === LOGLVL
|
|
1673
|
+
if (this.log.level === LOGLVL.DEBUG) {
|
|
1670
1674
|
JsSIP.debug.disable('JsSIP:*');
|
|
1671
1675
|
} else {
|
|
1672
1676
|
JsSIP.debug.disable('JsSIP:*');
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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,16 +1793,16 @@ 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
|
|
|
1742
1800
|
// Unpack provisioning token if specified
|
|
1743
1801
|
if (this._options.provisionToken) {
|
|
1744
|
-
let pt
|
|
1802
|
+
let pt;
|
|
1745
1803
|
try {
|
|
1746
1804
|
pt = JSON.parse(atob(this._options.provisionToken));
|
|
1747
|
-
} catch
|
|
1805
|
+
} catch {
|
|
1748
1806
|
throw new InvalidOptions('provisionToken', 'Invalid token');
|
|
1749
1807
|
}
|
|
1750
1808
|
|
|
@@ -1820,7 +1878,7 @@ class Phone extends EventEmitter {
|
|
|
1820
1878
|
);
|
|
1821
1879
|
}
|
|
1822
1880
|
|
|
1823
|
-
let wsSocket
|
|
1881
|
+
let wsSocket;
|
|
1824
1882
|
try {
|
|
1825
1883
|
wsSocket = new JsSIP.WebSocketInterface(this._options.wssServer);
|
|
1826
1884
|
} catch (err) {
|
|
@@ -1898,8 +1956,11 @@ class Phone extends EventEmitter {
|
|
|
1898
1956
|
self._setConnectionState('UNREGISTERED');
|
|
1899
1957
|
});
|
|
1900
1958
|
|
|
1901
|
-
this._UA.on('registrationFailed', function (
|
|
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');
|
|
@@ -1932,14 +1998,26 @@ class Phone extends EventEmitter {
|
|
|
1932
1998
|
try {
|
|
1933
1999
|
this._eventClient.disconnect();
|
|
1934
2000
|
delete this._eventClient;
|
|
1935
|
-
} catch
|
|
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
|
|
@@ -2023,7 +2101,7 @@ class Phone extends EventEmitter {
|
|
|
2023
2101
|
*/
|
|
2024
2102
|
getSessionByUUID(uuid) {
|
|
2025
2103
|
for (let key in this._activeSessions) {
|
|
2026
|
-
if (this._activeSessions
|
|
2104
|
+
if (Object.hasOwn(this._activeSessions, key)) {
|
|
2027
2105
|
if (this._activeSessions[key].uuid === uuid) {
|
|
2028
2106
|
return this._activeSessions[key];
|
|
2029
2107
|
}
|
|
@@ -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]
|
|
@@ -2048,7 +2147,7 @@ class Phone extends EventEmitter {
|
|
|
2048
2147
|
get sessions() {
|
|
2049
2148
|
let s = [];
|
|
2050
2149
|
for (let key in this._activeSessions) {
|
|
2051
|
-
if (this._activeSessions
|
|
2150
|
+
if (Object.hasOwn(this._activeSessions, key)) {
|
|
2052
2151
|
s.push(this._activeSessions[key]);
|
|
2053
2152
|
}
|
|
2054
2153
|
}
|
|
@@ -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
|
-
|
|
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
|
|
@@ -2213,7 +2349,7 @@ class Phone extends EventEmitter {
|
|
|
2213
2349
|
|
|
2214
2350
|
// Iterate through all active calls and hold them
|
|
2215
2351
|
for (let key in this._activeSessions) {
|
|
2216
|
-
if (this._activeSessions
|
|
2352
|
+
if (Object.hasOwn(this._activeSessions, key)) {
|
|
2217
2353
|
let session = this._activeSessions[key];
|
|
2218
2354
|
if (session.state === 'ACTIVE') {
|
|
2219
2355
|
session.hold();
|
|
@@ -2287,7 +2423,7 @@ class Phone extends EventEmitter {
|
|
|
2287
2423
|
// Return true if there are calls currently connecting
|
|
2288
2424
|
_checkForConnectingCalls() {
|
|
2289
2425
|
for (let key in this._activeSessions) {
|
|
2290
|
-
if (this._activeSessions
|
|
2426
|
+
if (Object.hasOwn(this._activeSessions, key)) {
|
|
2291
2427
|
let session = this._activeSessions[key];
|
|
2292
2428
|
if (
|
|
2293
2429
|
session.state === 'INITIALIZING' ||
|
|
@@ -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
|
-
|
|
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:
|
|
2616
|
+
mediaConstraints: { audio: this._phone.audioConstraints, video: false },
|
|
2478
2617
|
pcConfig: {
|
|
2479
2618
|
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
|
2480
2619
|
},
|
|
@@ -2671,7 +2810,7 @@ class Session extends EventEmitter {
|
|
|
2671
2810
|
self.log.info(`Answer call timeout after ${self._answerCallTimeout} seconds`);
|
|
2672
2811
|
try {
|
|
2673
2812
|
self.hangup();
|
|
2674
|
-
} catch
|
|
2813
|
+
} catch {
|
|
2675
2814
|
self.log.info('No active session, ignoring answer call timeout');
|
|
2676
2815
|
}
|
|
2677
2816
|
}, self._answerCallTimeout * 1000);
|
|
@@ -2828,7 +2967,7 @@ class Session extends EventEmitter {
|
|
|
2828
2967
|
*/
|
|
2829
2968
|
answer(metaInfo = null) {
|
|
2830
2969
|
let options = {
|
|
2831
|
-
mediaConstraints: { audio:
|
|
2970
|
+
mediaConstraints: { audio: this._phone.audioConstraints, video: false },
|
|
2832
2971
|
pcConfig: {
|
|
2833
2972
|
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
|
2834
2973
|
},
|
|
@@ -3742,4 +3881,4 @@ class Session extends EventEmitter {
|
|
|
3742
3881
|
}
|
|
3743
3882
|
}
|
|
3744
3883
|
|
|
3745
|
-
export { EventClient, EventServiceNotConnected, InvalidMediaDevice, InvalidOptions, InvalidParameter, LOGLVL
|
|
3884
|
+
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 };
|