@wazo/sdk 1.0.0-alpha.1 → 1.0.0-alpha.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/dist/wazo-sdk.js +6 -6
- package/esm/__tests__/__voice__/call.test.js +44 -2
- package/esm/domain/Phone/WebRTCPhone.js +6 -7
- package/esm/simple/Phone.js +1 -0
- package/esm/state-machine/call-state-machine.js +1 -1
- package/esm/types/domain/Phone/WebRTCPhone.d.ts.map +1 -1
- package/esm/types/simple/Phone.d.ts.map +1 -1
- package/esm/types/state-machine/utils.d.ts +1 -1
- package/esm/types/state-machine/utils.d.ts.map +1 -1
- package/esm/types/utils/sdp.d.ts +2 -0
- package/esm/types/utils/sdp.d.ts.map +1 -1
- package/esm/types/utils/webrtc.d.ts +1 -0
- package/esm/types/utils/webrtc.d.ts.map +1 -1
- package/esm/types/voice/call.d.ts +19 -11
- package/esm/types/voice/call.d.ts.map +1 -1
- package/esm/types/voice/softphone.d.ts +18 -13
- package/esm/types/voice/softphone.d.ts.map +1 -1
- package/esm/utils/sdp.js +13 -0
- package/esm/utils/webrtc.js +20 -0
- package/esm/voice/call.js +90 -30
- package/esm/voice/softphone.js +94 -23
- package/lib/__tests__/__voice__/call.test.js +43 -1
- package/lib/domain/Phone/WebRTCPhone.js +5 -6
- package/lib/simple/Phone.js +1 -0
- package/lib/state-machine/call-state-machine.js +1 -1
- package/lib/types/domain/Phone/WebRTCPhone.d.ts.map +1 -1
- package/lib/types/simple/Phone.d.ts.map +1 -1
- package/lib/types/state-machine/utils.d.ts +1 -1
- package/lib/types/state-machine/utils.d.ts.map +1 -1
- package/lib/types/utils/sdp.d.ts +2 -0
- package/lib/types/utils/sdp.d.ts.map +1 -1
- package/lib/types/utils/webrtc.d.ts +1 -0
- package/lib/types/utils/webrtc.d.ts.map +1 -1
- package/lib/types/voice/call.d.ts +19 -11
- package/lib/types/voice/call.d.ts.map +1 -1
- package/lib/types/voice/softphone.d.ts +18 -13
- package/lib/types/voice/softphone.d.ts.map +1 -1
- package/lib/utils/sdp.js +16 -1
- package/lib/utils/webrtc.js +22 -1
- package/lib/voice/call.js +87 -28
- package/lib/voice/softphone.js +95 -23
- package/package.json +1 -1
- package/src/__tests__/__voice__/call.test.ts +47 -2
- package/src/domain/Phone/WebRTCPhone.ts +9 -9
- package/src/simple/Phone.ts +1 -0
- package/src/state-machine/call-state-machine.ts +1 -1
- package/src/state-machine/utils.ts +2 -2
- package/src/utils/sdp.ts +16 -0
- package/src/utils/webrtc.ts +23 -1
- package/src/voice/call.ts +118 -40
- package/src/voice/softphone.ts +116 -30
- package/src/web-rtc-client.ts +1 -1
package/esm/voice/call.js
CHANGED
|
@@ -5,9 +5,10 @@ import { Invitation } from 'sip.js/lib/api/invitation';
|
|
|
5
5
|
import { SessionState } from 'sip.js/lib/api/session-state';
|
|
6
6
|
import callStateMachine, { Actions, EstablishedActions, States, EstablishedStates } from '../state-machine/call-state-machine';
|
|
7
7
|
import IssueReporter from '../service/IssueReporter';
|
|
8
|
-
import {
|
|
8
|
+
import softphone, { EVENT_TERMINATE_SOUND } from './softphone';
|
|
9
|
+
import { getCallDisplayName, getCallNumber, getSipSessionId } from '../utils/sdp';
|
|
9
10
|
import { assertCan, assertState, getState } from '../state-machine/utils';
|
|
10
|
-
import { downgradeToAudio, getLocalStream, getLocalVideoStream, getPeerConnection, getRemoteStream, getRemoteVideoStream, getStreamFromConstraints, getWebRtcStats, hasALocalVideoTrack, hasLocalVideo, hasRemoteVideo, isVideoRemotelyHeld, setLocalMediaStream, toggleVideo, upgradeToVideo } from '../utils/webrtc';
|
|
11
|
+
import { downgradeToAudio, getLocalStream, getLocalVideoStream, getPeerConnection, getRemoteStream, getRemoteVideoStream, getStreamFromConstraints, getWebRtcStats, hasALocalVideoTrack, hasLocalVideo, hasRemoteVideo, isVideoMuted, isVideoRemotelyHeld, setLocalMediaStream, toggleVideo, upgradeToVideo } from '../utils/webrtc';
|
|
11
12
|
const logger = IssueReporter.loggerFor('call');
|
|
12
13
|
// Events
|
|
13
14
|
export const EVENT_CALL_ACCEPTED = 'accepted';
|
|
@@ -33,12 +34,10 @@ export const EVENT_SHARE_SCREEN_ENDED = 'onScreenShareEnded';
|
|
|
33
34
|
export const EVENT_WEB_RTC_STATS = 'webRtcStats';
|
|
34
35
|
export const EVENT_CAMERA_RESUMED = 'onCameraResumed';
|
|
35
36
|
export const EVENT_CAMERA_DISABLED = 'onCameraDisabled';
|
|
36
|
-
export const EVENT_TERMINATE_SOUND = 'terminateSound';
|
|
37
37
|
export const MESSAGE_TYPE_CHAT = 'message/TYPE_CHAT';
|
|
38
38
|
export const MESSAGE_TYPE_SIGNAL = 'message/TYPE_SIGNAL';
|
|
39
39
|
export const MESSAGE_TRACK_UPDATED = 'onTrackUpdated';
|
|
40
40
|
class Call extends EventEmitter {
|
|
41
|
-
eventEmitter;
|
|
42
41
|
callActor;
|
|
43
42
|
// Wazo call id, used in Wazo Websocket and API
|
|
44
43
|
apiId;
|
|
@@ -49,7 +48,52 @@ class Call extends EventEmitter {
|
|
|
49
48
|
endTime;
|
|
50
49
|
phone;
|
|
51
50
|
shouldSendReinvite = false;
|
|
51
|
+
recording = false;
|
|
52
|
+
displayName;
|
|
53
|
+
number;
|
|
52
54
|
currentScreenShare;
|
|
55
|
+
static parseCall(apiCall) {
|
|
56
|
+
const sipCall = {
|
|
57
|
+
id: apiCall.sipCallId,
|
|
58
|
+
stateChange: {
|
|
59
|
+
addListener: () => { },
|
|
60
|
+
},
|
|
61
|
+
remoteIdentity: {
|
|
62
|
+
uri: {
|
|
63
|
+
_normal: {
|
|
64
|
+
user: apiCall.calleeNumber,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
// Mock sdk so `hasLocalVideo` will return true.
|
|
69
|
+
sessionDescriptionHandler: {
|
|
70
|
+
localMediaStream: {
|
|
71
|
+
getTracks: () => (apiCall.isVideo ? [{ kind: 'video', readyState: 'live' }] : []),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const call = new Call(sipCall, softphone);
|
|
76
|
+
// call._sendAction(Actions.ES);
|
|
77
|
+
call.apiId = apiCall.id;
|
|
78
|
+
call.answerTime = new Date(+apiCall.startingTime);
|
|
79
|
+
// Update call state
|
|
80
|
+
const snapshot = call.callActor.getSnapshot();
|
|
81
|
+
if (apiCall.isUp()) {
|
|
82
|
+
snapshot.value = { [States.ESTABLISHED]: EstablishedStates.ONGOING };
|
|
83
|
+
}
|
|
84
|
+
if (apiCall.muted) {
|
|
85
|
+
snapshot.value = { [States.ESTABLISHED]: EstablishedStates.MUTED };
|
|
86
|
+
}
|
|
87
|
+
if (apiCall.isRinging()) {
|
|
88
|
+
snapshot.value = States.RINGING;
|
|
89
|
+
}
|
|
90
|
+
if (apiCall.isRecording()) {
|
|
91
|
+
call.recording = true;
|
|
92
|
+
}
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
call.callActor.update(snapshot);
|
|
95
|
+
return call;
|
|
96
|
+
}
|
|
53
97
|
constructor(sipCall, phone) {
|
|
54
98
|
super();
|
|
55
99
|
this.callActor = createActor(callStateMachine);
|
|
@@ -57,6 +101,8 @@ class Call extends EventEmitter {
|
|
|
57
101
|
this.creationTime = new Date();
|
|
58
102
|
this.phone = phone;
|
|
59
103
|
this.sipCall = sipCall;
|
|
104
|
+
this.number = getCallNumber(sipCall);
|
|
105
|
+
this.displayName = getCallDisplayName(sipCall);
|
|
60
106
|
this._bindEvents();
|
|
61
107
|
}
|
|
62
108
|
async accept(options = {}) {
|
|
@@ -78,7 +124,7 @@ class Call extends EventEmitter {
|
|
|
78
124
|
async reject() {
|
|
79
125
|
assertCan(this.callActor, Actions.REJECT);
|
|
80
126
|
logger.info('call - reject', { id: this.id });
|
|
81
|
-
this.
|
|
127
|
+
this.phone.emit(EVENT_TERMINATE_SOUND, 'call rejected locally');
|
|
82
128
|
this.shouldSendReinvite = false;
|
|
83
129
|
return this.hangup();
|
|
84
130
|
}
|
|
@@ -87,7 +133,7 @@ class Call extends EventEmitter {
|
|
|
87
133
|
logger.info('call - hangup', { id: this.id });
|
|
88
134
|
await this.phone.client.hangup(this.sipCall);
|
|
89
135
|
// Remove this call from softphone's calls
|
|
90
|
-
this.phone.
|
|
136
|
+
this.phone.onCallTerminated(this);
|
|
91
137
|
return true;
|
|
92
138
|
}
|
|
93
139
|
forceCancel() {
|
|
@@ -99,7 +145,7 @@ class Call extends EventEmitter {
|
|
|
99
145
|
logger.info('call - force cancel, error', e);
|
|
100
146
|
}
|
|
101
147
|
}
|
|
102
|
-
startConference(withCalls) {
|
|
148
|
+
startConference( /* withCalls: Call[] */) {
|
|
103
149
|
// @TODO
|
|
104
150
|
// const participants = [this, ...withCalls].reduce((acc: Record<string, any>, call: Call) => {
|
|
105
151
|
// acc[call.talkingToIds[0]] = call;
|
|
@@ -117,14 +163,14 @@ class Call extends EventEmitter {
|
|
|
117
163
|
logger.info('call - mute', { id: this.id });
|
|
118
164
|
this._sendAction(EstablishedActions.MUTE);
|
|
119
165
|
this.phone.client.mute(this.sipCall);
|
|
120
|
-
this.
|
|
166
|
+
this.emit(EVENT_CALL_MUTED);
|
|
121
167
|
}
|
|
122
168
|
unMute() {
|
|
123
169
|
assertCan(this.callActor, EstablishedActions.UN_MUTE);
|
|
124
170
|
logger.info('call - un-mute', { id: this.id });
|
|
125
171
|
this._sendAction(EstablishedActions.UN_MUTE);
|
|
126
172
|
this.phone.client.unmute(this.sipCall);
|
|
127
|
-
this.
|
|
173
|
+
this.emit(EVENT_CALL_UN_MUTED);
|
|
128
174
|
}
|
|
129
175
|
async hold() {
|
|
130
176
|
assertCan(this.callActor, EstablishedActions.HOLD);
|
|
@@ -140,7 +186,7 @@ class Call extends EventEmitter {
|
|
|
140
186
|
await downgradeToAudio(this.sipCall);
|
|
141
187
|
}
|
|
142
188
|
const promise = this.phone.client.hold(this.sipCall, this.isConference(), hasVideo);
|
|
143
|
-
this.
|
|
189
|
+
this.emit(EVENT_CALL_HELD);
|
|
144
190
|
return promise;
|
|
145
191
|
}
|
|
146
192
|
async resume() {
|
|
@@ -167,7 +213,7 @@ class Call extends EventEmitter {
|
|
|
167
213
|
this._onScreenSharing(stream, hadVideo, wasDesktop);
|
|
168
214
|
}
|
|
169
215
|
};
|
|
170
|
-
this.
|
|
216
|
+
this.emit(EVENT_CALL_RESUMED);
|
|
171
217
|
return promise.then(() => {
|
|
172
218
|
const stream = this.getLocalVideoStream();
|
|
173
219
|
if (wasScreensharing) {
|
|
@@ -180,19 +226,25 @@ class Call extends EventEmitter {
|
|
|
180
226
|
assertState(this.callActor, States.ESTABLISHED);
|
|
181
227
|
logger.info('call - turn camera on', { id: this.id });
|
|
182
228
|
toggleVideo(this.sipCall, true);
|
|
183
|
-
this.
|
|
229
|
+
this.emit(EVENT_CAMERA_RESUMED);
|
|
184
230
|
}
|
|
185
231
|
turnCameraOff() {
|
|
186
232
|
assertState(this.callActor, States.ESTABLISHED);
|
|
187
233
|
logger.info('call - turn camera off', { id: this.id });
|
|
188
234
|
toggleVideo(this.sipCall, false);
|
|
189
|
-
this.
|
|
235
|
+
this.emit(EVENT_CAMERA_DISABLED);
|
|
236
|
+
}
|
|
237
|
+
isMuted() {
|
|
238
|
+
return this._hasEstablishedState(EstablishedStates.MUTED);
|
|
239
|
+
}
|
|
240
|
+
isCameraMuted() {
|
|
241
|
+
return isVideoMuted(this.sipCall);
|
|
190
242
|
}
|
|
191
243
|
isHeld() {
|
|
192
|
-
return
|
|
244
|
+
return this._hasEstablishedState(EstablishedStates.HELD);
|
|
193
245
|
}
|
|
194
246
|
isEstablished() {
|
|
195
|
-
return
|
|
247
|
+
return States.ESTABLISHED in this.state;
|
|
196
248
|
}
|
|
197
249
|
isConference() {
|
|
198
250
|
return getPeerConnection(this.sipCall)?.sfu;
|
|
@@ -320,7 +372,7 @@ class Call extends EventEmitter {
|
|
|
320
372
|
catch (e) {
|
|
321
373
|
console.warn(e);
|
|
322
374
|
}
|
|
323
|
-
this.
|
|
375
|
+
this.emit(EVENT_SHARE_SCREEN_ENDED);
|
|
324
376
|
this.currentScreenShare = null;
|
|
325
377
|
return reinvited;
|
|
326
378
|
}
|
|
@@ -345,10 +397,13 @@ class Call extends EventEmitter {
|
|
|
345
397
|
onError(error) {
|
|
346
398
|
this.emit(EVENT_CALL_ERROR, error);
|
|
347
399
|
}
|
|
400
|
+
onCallIncoming() {
|
|
401
|
+
this._sendAction(Actions.INCOMING_CALL);
|
|
402
|
+
}
|
|
348
403
|
onRejected() {
|
|
349
404
|
this.endTime = new Date();
|
|
350
405
|
this.emit(EVENT_CALL_REJECTED);
|
|
351
|
-
this.phone.
|
|
406
|
+
this.phone.onCallTerminated(this);
|
|
352
407
|
}
|
|
353
408
|
onNetworkStats(stats, previousStats) {
|
|
354
409
|
this.emit(EVENT_WEB_RTC_STATS, stats, previousStats);
|
|
@@ -377,16 +432,21 @@ class Call extends EventEmitter {
|
|
|
377
432
|
isVideoRemotelyHeld() {
|
|
378
433
|
return isVideoRemotelyHeld(this.sipCall);
|
|
379
434
|
}
|
|
435
|
+
isRinging() {
|
|
436
|
+
return this.state === States.RINGING;
|
|
437
|
+
}
|
|
380
438
|
onReinvite(request, updatedCalleeName, updatedNumber, hadRemoteVideo) {
|
|
381
439
|
this.emit(EVENT_REINVITE, request, updatedCalleeName, updatedNumber, hadRemoteVideo);
|
|
382
440
|
}
|
|
383
441
|
get id() {
|
|
384
442
|
return getSipSessionId(this.sipCall);
|
|
385
443
|
}
|
|
386
|
-
get
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
444
|
+
get state() {
|
|
445
|
+
return getState(this.callActor);
|
|
446
|
+
}
|
|
447
|
+
_hasEstablishedState(establishedState) {
|
|
448
|
+
const state = this.state;
|
|
449
|
+
return States.ESTABLISHED in state && state[States.ESTABLISHED] === establishedState;
|
|
390
450
|
}
|
|
391
451
|
_bindEvents() {
|
|
392
452
|
if (this.sipCall instanceof Invitation) {
|
|
@@ -396,21 +456,22 @@ class Call extends EventEmitter {
|
|
|
396
456
|
logger.trace('call - on sip session canceled', { callId: message.callId, id: this.id });
|
|
397
457
|
onCancel(message);
|
|
398
458
|
const elsewhere = message.data.indexOf('cause=26') !== -1 && message.data.indexOf('completed elsewhere') !== -1;
|
|
399
|
-
this.
|
|
459
|
+
this.emit(EVENT_CALL_CANCELED, elsewhere);
|
|
400
460
|
};
|
|
401
461
|
}
|
|
402
462
|
this.sipCall.stateChange.addListener((newState) => {
|
|
403
463
|
switch (newState) {
|
|
404
464
|
case SessionState.Terminating:
|
|
405
465
|
logger.info('call - terminating', { id: this.id });
|
|
406
|
-
this.
|
|
466
|
+
this.emit(EVENT_CALL_TERMINATING);
|
|
407
467
|
break;
|
|
408
468
|
case SessionState.Terminated:
|
|
409
469
|
{
|
|
410
470
|
logger.info('call - terminated', { id: this.id });
|
|
411
471
|
// Should be called before `_onCallTerminated` or the callCount will not decrement...
|
|
412
472
|
this.endTime = new Date();
|
|
413
|
-
|
|
473
|
+
this.emit(EVENT_CALL_ENDED);
|
|
474
|
+
return this.phone.onCallTerminated(this);
|
|
414
475
|
}
|
|
415
476
|
default:
|
|
416
477
|
break;
|
|
@@ -437,18 +498,17 @@ class Call extends EventEmitter {
|
|
|
437
498
|
kind,
|
|
438
499
|
});
|
|
439
500
|
if (kind === 'audio') {
|
|
440
|
-
return this.
|
|
501
|
+
return this.emit(EVENT_AUDIO_STREAM, stream);
|
|
441
502
|
}
|
|
442
|
-
return this.
|
|
503
|
+
return this.emit(EVENT_VIDEO_STREAM, stream, event.track.id, event);
|
|
443
504
|
};
|
|
444
505
|
peerConnection.onremovestream = (event) => {
|
|
445
506
|
logger.info('call - on remove stream called on the peer connection', {
|
|
446
507
|
id: event.stream.id,
|
|
447
508
|
tracks: event.stream.getTracks(),
|
|
448
509
|
});
|
|
449
|
-
this.
|
|
510
|
+
this.emit(EVENT_REMOVE_STREAM, event.stream);
|
|
450
511
|
};
|
|
451
|
-
// Client events
|
|
452
512
|
}
|
|
453
513
|
_downgradeToAudio(withMessage = true) {
|
|
454
514
|
assertState(this.callActor, States.ESTABLISHED);
|
|
@@ -508,7 +568,7 @@ class Call extends EventEmitter {
|
|
|
508
568
|
if (screenTrack) {
|
|
509
569
|
screenTrack.onended = () => {
|
|
510
570
|
logger.info('call - on screenshare ended', { hadVideo, id: this.id, screenTrack });
|
|
511
|
-
this.
|
|
571
|
+
this.emit(EVENT_SHARE_SCREEN_ENDING);
|
|
512
572
|
};
|
|
513
573
|
}
|
|
514
574
|
this.currentScreenShare = {
|
|
@@ -518,7 +578,7 @@ class Call extends EventEmitter {
|
|
|
518
578
|
desktop,
|
|
519
579
|
};
|
|
520
580
|
setLocalMediaStream(this.sipCall, screenStream);
|
|
521
|
-
this.
|
|
581
|
+
this.emit(EVENT_SHARE_SCREEN_STARTED, screenStream);
|
|
522
582
|
}
|
|
523
583
|
_sendAction(action) {
|
|
524
584
|
this.callActor.send({ type: action });
|
package/esm/voice/softphone.js
CHANGED
|
@@ -4,19 +4,23 @@ import { createActor } from 'xstate';
|
|
|
4
4
|
import InvalidState from '../domain/InvalidState';
|
|
5
5
|
import Wazo from '..';
|
|
6
6
|
import softphoneStateMachine, { Actions, States } from '../state-machine/softphone-state-machine';
|
|
7
|
-
import { Actions as CallActions } from '../state-machine/call-state-machine';
|
|
8
7
|
import WazoWebRTCClient from '../web-rtc-client';
|
|
9
8
|
import IssueReporter from '../service/IssueReporter';
|
|
10
9
|
import configureLogger from '../utils/sip-logger';
|
|
11
10
|
import Call from './call';
|
|
12
11
|
import { assertCan, can, getState, waitUntilState } from '../state-machine/utils';
|
|
13
12
|
export const EVENT_INCOMING = 'incoming';
|
|
13
|
+
export const EVENT_OUTGOING = 'outgoing';
|
|
14
14
|
export const EVENT_HEARTBEAT = 'heartbeat';
|
|
15
15
|
export const EVENT_HEARTBEAT_TIMEOUT = 'heartbeatTimeout';
|
|
16
16
|
export const EVENT_REGISTERED = 'registered';
|
|
17
17
|
export const EVENT_UNREGISTERED = 'unregistered';
|
|
18
18
|
export const EVENT_DISCONNECTED = 'disconnected';
|
|
19
19
|
export const EVENT_VIDEO_INPUT_CHANGE = 'videoInputChange';
|
|
20
|
+
export const EVENT_TERMINATE_SOUND = 'terminateSound';
|
|
21
|
+
export const EVENT_PLAY_HANGUP_SOUND = 'playHangupSound';
|
|
22
|
+
export const EVENT_PLAY_RING_SOUND = 'playRingSound';
|
|
23
|
+
export const EVENT_PLAY_INBOUND_CALL_SOUND = 'playInboundCallSound';
|
|
20
24
|
export const EVENT_ON_MESSAGE = 'message';
|
|
21
25
|
export const EVENT_ON_CHAT = 'phone/ON_CHAT';
|
|
22
26
|
export const EVENT_ON_SIGNAL = 'phone/ON_SIGNAL';
|
|
@@ -28,7 +32,6 @@ export class Softphone extends EventEmitter {
|
|
|
28
32
|
audioRingDeviceId;
|
|
29
33
|
audioOutputVolume;
|
|
30
34
|
audioRingVolume;
|
|
31
|
-
eventEmitter;
|
|
32
35
|
calls;
|
|
33
36
|
client;
|
|
34
37
|
session;
|
|
@@ -36,6 +39,7 @@ export class Softphone extends EventEmitter {
|
|
|
36
39
|
softphoneActor;
|
|
37
40
|
options;
|
|
38
41
|
shouldSendReinvite;
|
|
42
|
+
ringingEnabled = true;
|
|
39
43
|
constructor() {
|
|
40
44
|
super();
|
|
41
45
|
this.calls = [];
|
|
@@ -114,19 +118,20 @@ export class Softphone extends EventEmitter {
|
|
|
114
118
|
logger.warn('softphone - calling with empty number, bailing.');
|
|
115
119
|
return Promise.resolve(null);
|
|
116
120
|
}
|
|
117
|
-
if (this.
|
|
118
|
-
throw new InvalidState(`Invalid state ${States.UNREGISTERED}`, States.UNREGISTERED);
|
|
119
|
-
}
|
|
120
|
-
if (this.getState() !== States.REGISTERING) {
|
|
121
|
+
if (this.state === States.REGISTERING) {
|
|
121
122
|
logger.info('softphone - calling when registering, waiting for Softphone to be registered ...');
|
|
122
123
|
await waitUntilState(this.softphoneActor, States.REGISTERED);
|
|
123
124
|
}
|
|
125
|
+
if (this.state !== States.UNREGISTERED) {
|
|
126
|
+
throw new InvalidState(`Invalid state ${States.UNREGISTERED}`, States.UNREGISTERED);
|
|
127
|
+
}
|
|
124
128
|
// Hold other calls
|
|
125
129
|
this.holdAllCalls();
|
|
126
130
|
let sipCall;
|
|
127
131
|
try {
|
|
128
132
|
sipCall = this.client.call(options.params.To, options.withCamera, options.audioOnly, options.conference, options);
|
|
129
133
|
const call = new Call(sipCall, this);
|
|
134
|
+
this.emit(EVENT_OUTGOING, call);
|
|
130
135
|
return call;
|
|
131
136
|
}
|
|
132
137
|
catch (error) {
|
|
@@ -137,6 +142,58 @@ export class Softphone extends EventEmitter {
|
|
|
137
142
|
removeCall(callToRemove) {
|
|
138
143
|
this.calls = this.calls.filter((call) => call.id !== callToRemove.id);
|
|
139
144
|
}
|
|
145
|
+
onCallTerminated(call) {
|
|
146
|
+
logger.info('softphone - on call terminated', { id: call.id });
|
|
147
|
+
const currentCall = this.getCurrentCall();
|
|
148
|
+
const isCurrentCall = currentCall?.id === call.id;
|
|
149
|
+
const isRinging = call.isRinging();
|
|
150
|
+
const isCurrentIncomingCall = isCurrentCall && isRinging;
|
|
151
|
+
const incomingCalls = this.getIncomingCalls();
|
|
152
|
+
const nbIncomingCalls = incomingCalls.length;
|
|
153
|
+
// Send an event to terminate ringing sound
|
|
154
|
+
if (isCurrentIncomingCall) {
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
// Avoid race condition when the other is calling and hanging up immediately
|
|
157
|
+
this.emit(EVENT_TERMINATE_SOUND, call, 'call terminated');
|
|
158
|
+
}, 5);
|
|
159
|
+
}
|
|
160
|
+
// If the terminated call was an incoming call, we have to re-trigger if it's was the first incoming call
|
|
161
|
+
// Otherwise, we have to re-trigger an incoming call event if another call is incoming
|
|
162
|
+
const shouldRetrigger = isCurrentCall ? nbIncomingCalls : isCurrentIncomingCall;
|
|
163
|
+
// Re-trigger incoming call event for remaining incoming calls
|
|
164
|
+
logger.info('Softphone - check to re-trigger incoming call', { shouldRetrigger, nbIncomingCalls });
|
|
165
|
+
if (shouldRetrigger && nbIncomingCalls > 1) {
|
|
166
|
+
const nextCall = incomingCalls.find(c => c.id !== call.id);
|
|
167
|
+
// Avoid race condition
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (this.ringingEnabled) {
|
|
170
|
+
if (currentCall) {
|
|
171
|
+
this.emit(EVENT_PLAY_RING_SOUND, this.audioRingDeviceId, this.audioRingVolume, nextCall);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.emit(EVENT_PLAY_INBOUND_CALL_SOUND, this.audioOutputDeviceId, this.audioOutputVolume, nextCall);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
this.emit(EVENT_INCOMING, nextCall);
|
|
178
|
+
}, 100);
|
|
179
|
+
}
|
|
180
|
+
// @ts-ignore: does not exist
|
|
181
|
+
if (!call.sipCall.isCanceled) {
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
this.emit(EVENT_PLAY_HANGUP_SOUND, this.audioOutputDeviceId, this.audioOutputVolume, call);
|
|
184
|
+
}, 10);
|
|
185
|
+
}
|
|
186
|
+
this.client.onCallEnded(call.sipCall);
|
|
187
|
+
this.removeCall(call);
|
|
188
|
+
}
|
|
189
|
+
enableRinging() {
|
|
190
|
+
logger.info('softphone - enable ringing');
|
|
191
|
+
this.ringingEnabled = true;
|
|
192
|
+
}
|
|
193
|
+
disableRinging() {
|
|
194
|
+
logger.info('softphone - disable ringing');
|
|
195
|
+
this.ringingEnabled = false;
|
|
196
|
+
}
|
|
140
197
|
holdAllCalls(exceptCall = null) {
|
|
141
198
|
this.calls.filter(call => !call.isHeld() && call.id === exceptCall?.id).forEach(call => call.hold());
|
|
142
199
|
}
|
|
@@ -190,24 +247,36 @@ export class Softphone extends EventEmitter {
|
|
|
190
247
|
logger.info('softphone - changing ring volume', { volume });
|
|
191
248
|
this.audioRingVolume = volume;
|
|
192
249
|
}
|
|
193
|
-
getCalls() {
|
|
194
|
-
return this.calls;
|
|
195
|
-
}
|
|
196
250
|
getCurrentCall() {
|
|
197
251
|
return this.calls.find(call => call.isEstablished());
|
|
198
252
|
}
|
|
199
|
-
|
|
200
|
-
return this.
|
|
253
|
+
getIncomingCalls() {
|
|
254
|
+
return this.calls.filter(call => call.isRinging());
|
|
255
|
+
}
|
|
256
|
+
get state() {
|
|
257
|
+
return getState(this.softphoneActor);
|
|
201
258
|
}
|
|
202
259
|
getUserAgent() {
|
|
203
260
|
return this.client?.config?.userAgentString || 'softphone';
|
|
204
261
|
}
|
|
262
|
+
isRegistered() {
|
|
263
|
+
return this.state === States.REGISTERED;
|
|
264
|
+
}
|
|
205
265
|
_bindEvents() {
|
|
206
266
|
this.client.on(this.client.INVITE, (sipSession) => {
|
|
207
267
|
const call = new Call(sipSession, this);
|
|
208
|
-
|
|
268
|
+
logger.info('softphone - on invite', { callId: call.id, number: call.number });
|
|
269
|
+
call.onCallIncoming();
|
|
209
270
|
this.calls.push(call);
|
|
210
|
-
this.
|
|
271
|
+
this.emit(EVENT_INCOMING, call);
|
|
272
|
+
if (!this.getCurrentCall()) {
|
|
273
|
+
if (this.ringingEnabled) {
|
|
274
|
+
this.emit(EVENT_PLAY_RING_SOUND, this.audioRingDeviceId, this.audioRingVolume, call);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.emit(EVENT_PLAY_INBOUND_CALL_SOUND, this.audioOutputDeviceId, this.audioOutputVolume, call);
|
|
279
|
+
}
|
|
211
280
|
});
|
|
212
281
|
this.client.on(this.client.ON_REINVITE, (sipCall, request, updatedCalleeName, updatedNumber, hadRemoteVideo) => {
|
|
213
282
|
logger.info('softphone - on reinvite', {
|
|
@@ -220,6 +289,8 @@ export class Softphone extends EventEmitter {
|
|
|
220
289
|
this._onCallEvent(sipCall, 'onReinvite', request, updatedCalleeName, updatedNumber, hadRemoteVideo);
|
|
221
290
|
});
|
|
222
291
|
this.client.on(this.client.ACCEPTED, async (sipCall) => {
|
|
292
|
+
const call = this._getCall(sipCall);
|
|
293
|
+
this.emit(EVENT_TERMINATE_SOUND, call, 'call accepted');
|
|
223
294
|
this._onCallEvent(sipCall, 'onAccepted');
|
|
224
295
|
if (this.audioOutputDeviceId) {
|
|
225
296
|
await this.client.changeAudioOutputDevice(this.audioOutputDeviceId);
|
|
@@ -249,12 +320,12 @@ export class Softphone extends EventEmitter {
|
|
|
249
320
|
});
|
|
250
321
|
this.client.on(this.client.UNREGISTERED, () => {
|
|
251
322
|
logger.info('softphone - unregistered');
|
|
252
|
-
this.
|
|
323
|
+
this.emit(EVENT_UNREGISTERED);
|
|
253
324
|
this._sendAction(Actions.UNREGISTERED);
|
|
254
325
|
});
|
|
255
326
|
this.client.on(this.client.ON_DISCONNECTED, () => {
|
|
256
327
|
logger.info('softphone - disconnected');
|
|
257
|
-
this.
|
|
328
|
+
this.emit(EVENT_DISCONNECTED);
|
|
258
329
|
this._sendAction(Actions.TRANSPORT_CLOSED);
|
|
259
330
|
});
|
|
260
331
|
this.client.on(this.client.REGISTERED, () => {
|
|
@@ -265,7 +336,7 @@ export class Softphone extends EventEmitter {
|
|
|
265
336
|
}
|
|
266
337
|
this.stopHeartbeat();
|
|
267
338
|
this._sendAction(Actions.REGISTER_DONE);
|
|
268
|
-
this.
|
|
339
|
+
this.emit(EVENT_REGISTERED);
|
|
269
340
|
// If the phone registered with a current callSession (eg: when switching network):
|
|
270
341
|
// send a reinvite to renegociate ICE with new IP
|
|
271
342
|
if (this.shouldSendReinvite) {
|
|
@@ -281,7 +352,7 @@ export class Softphone extends EventEmitter {
|
|
|
281
352
|
});
|
|
282
353
|
this.client.on(this.client.DISCONNECTED, () => {
|
|
283
354
|
logger.info('softphone - disconnected');
|
|
284
|
-
this.
|
|
355
|
+
this.emit(EVENT_DISCONNECTED);
|
|
285
356
|
// Do not trigger heartbeat if already running
|
|
286
357
|
if (!this.client.hasHeartbeat()) {
|
|
287
358
|
this.startHeartbeat();
|
|
@@ -290,17 +361,17 @@ export class Softphone extends EventEmitter {
|
|
|
290
361
|
this.shouldSendReinvite = this.calls.length > 0;
|
|
291
362
|
});
|
|
292
363
|
this.client.on('onVideoInputChange', stream => {
|
|
293
|
-
this.
|
|
364
|
+
this.emit(EVENT_VIDEO_INPUT_CHANGE, stream);
|
|
294
365
|
});
|
|
295
366
|
this.client.on(this.client.MESSAGE, (message) => {
|
|
296
367
|
this._onMessage(message);
|
|
297
|
-
this.
|
|
368
|
+
this.emit(EVENT_ON_MESSAGE, message);
|
|
298
369
|
});
|
|
299
370
|
this.client.setOnHeartbeatTimeout(() => {
|
|
300
|
-
this.
|
|
371
|
+
this.emit(EVENT_HEARTBEAT_TIMEOUT);
|
|
301
372
|
});
|
|
302
373
|
this.client.setOnHeartbeatCallback(() => {
|
|
303
|
-
this.
|
|
374
|
+
this.emit(EVENT_HEARTBEAT);
|
|
304
375
|
});
|
|
305
376
|
}
|
|
306
377
|
_sendAction(action) {
|
|
@@ -333,11 +404,11 @@ export class Softphone extends EventEmitter {
|
|
|
333
404
|
const { type, content, } = body;
|
|
334
405
|
switch (type) {
|
|
335
406
|
case MESSAGE_TYPE_CHAT:
|
|
336
|
-
this.
|
|
407
|
+
this.emit(EVENT_ON_CHAT, content);
|
|
337
408
|
break;
|
|
338
409
|
case MESSAGE_TYPE_SIGNAL:
|
|
339
410
|
{
|
|
340
|
-
this.
|
|
411
|
+
this.emit(EVENT_ON_SIGNAL, content);
|
|
341
412
|
break;
|
|
342
413
|
}
|
|
343
414
|
default:
|
|
@@ -18,6 +18,7 @@ const softphone_1 = __importDefault(require("../../voice/softphone"));
|
|
|
18
18
|
const call_1 = __importDefault(require("../../voice/call"));
|
|
19
19
|
const call_state_machine_1 = require("../../state-machine/call-state-machine");
|
|
20
20
|
const softphone_state_machine_1 = require("../../state-machine/softphone-state-machine");
|
|
21
|
+
const Call_1 = __importDefault(require("../../domain/Call"));
|
|
21
22
|
const InvalidStateTransition_1 = __importDefault(require("../../domain/InvalidStateTransition"));
|
|
22
23
|
const uaOptions = {
|
|
23
24
|
logBuiltinEnabled: false,
|
|
@@ -42,6 +43,7 @@ jest.mock('../../web-rtc-client', () => jest.fn().mockImplementation(() => ({
|
|
|
42
43
|
answer: jest.fn(() => Promise.resolve()),
|
|
43
44
|
hangup: jest.fn(() => Promise.resolve()),
|
|
44
45
|
register: jest.fn(() => Promise.resolve()),
|
|
46
|
+
onCallEnded: jest.fn(() => Promise.resolve()),
|
|
45
47
|
setOnHeartbeatTimeout: jest.fn(() => Promise.resolve()),
|
|
46
48
|
setOnHeartbeatCallback: jest.fn(() => Promise.resolve()),
|
|
47
49
|
on: jest.fn(),
|
|
@@ -49,22 +51,60 @@ jest.mock('../../web-rtc-client', () => jest.fn().mockImplementation(() => ({
|
|
|
49
51
|
const ua = new sip_js_1.UserAgent(uaOptions);
|
|
50
52
|
const sipCall = new sip_js_1.Inviter(ua, sip_js_1.UserAgent.makeURI('sip:1234@my.stack.io'));
|
|
51
53
|
softphone_1.default.connect();
|
|
54
|
+
// @ts-ignore: private method
|
|
52
55
|
softphone_1.default._sendAction(softphone_state_machine_1.Actions.REGISTER_DONE);
|
|
53
56
|
const invalidStateTransition = (state, action) => new InvalidStateTransition_1.default(`Invalid state transition from ${state} with action ${action}`, state, action);
|
|
54
57
|
describe('Call', () => {
|
|
58
|
+
describe('parsing an API call', () => {
|
|
59
|
+
it('Should parse an API call and transform it to a Call instance', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
60
|
+
const apiCall = new Call_1.default({
|
|
61
|
+
id: '1234',
|
|
62
|
+
sipCallId: 'abcd',
|
|
63
|
+
isCaller: false,
|
|
64
|
+
isVideo: true,
|
|
65
|
+
recording: true,
|
|
66
|
+
callerName: '',
|
|
67
|
+
callerNumber: '',
|
|
68
|
+
calleeName: '',
|
|
69
|
+
calleeNumber: '0123423223',
|
|
70
|
+
dialedExtension: '',
|
|
71
|
+
lineId: null,
|
|
72
|
+
muted: true,
|
|
73
|
+
onHold: false,
|
|
74
|
+
startingTime: new Date(),
|
|
75
|
+
status: 'Up',
|
|
76
|
+
talkingToIds: [],
|
|
77
|
+
});
|
|
78
|
+
const call = call_1.default.parseCall(apiCall);
|
|
79
|
+
expect(call.recording).toBeTruthy();
|
|
80
|
+
expect(call.id).toBe(apiCall.sipCallId);
|
|
81
|
+
expect(call.apiId).toBe(apiCall.id);
|
|
82
|
+
expect(call.number).toBe(apiCall.calleeNumber);
|
|
83
|
+
expect(call.answerTime).toStrictEqual(apiCall.startingTime);
|
|
84
|
+
expect(call.isMuted()).toBeTruthy();
|
|
85
|
+
expect(call.state).toStrictEqual({ [call_state_machine_1.States.ESTABLISHED]: call_state_machine_1.EstablishedStates.MUTED });
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
55
88
|
describe('accept', () => {
|
|
56
89
|
it('Should throw an error when accepting a not ringing call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
57
90
|
const call = new call_1.default(sipCall, softphone_1.default);
|
|
58
91
|
yield expect(call.accept()).rejects.toThrowError(invalidStateTransition(call_state_machine_1.States.IDLE, call_state_machine_1.Actions.ACCEPT));
|
|
59
92
|
}));
|
|
60
|
-
it
|
|
93
|
+
it('Should throw an error when the softphone is not registered', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
61
94
|
const call = new call_1.default(sipCall, softphone_1.default);
|
|
95
|
+
// @ts-ignore: private method
|
|
62
96
|
call._sendAction(call_state_machine_1.Actions.INCOMING_CALL);
|
|
97
|
+
// @ts-ignore: private method
|
|
63
98
|
softphone_1.default._sendAction(softphone_state_machine_1.Actions.TRANSPORT_CLOSED);
|
|
64
99
|
yield expect(call.accept()).rejects.toThrowError(invalidStateTransition(call_state_machine_1.States.RINGING, call_state_machine_1.Actions.ACCEPT));
|
|
100
|
+
// Revert softphone state
|
|
101
|
+
softphone_1.default.connect();
|
|
102
|
+
// @ts-ignore: private method
|
|
103
|
+
softphone_1.default._sendAction(softphone_state_machine_1.Actions.REGISTER_DONE);
|
|
65
104
|
}));
|
|
66
105
|
it('Should not throw an error when accepting a not ringing call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
67
106
|
const call = new call_1.default(sipCall, softphone_1.default);
|
|
107
|
+
// @ts-ignore: private method
|
|
68
108
|
call._sendAction(call_state_machine_1.Actions.INCOMING_CALL);
|
|
69
109
|
yield call.accept();
|
|
70
110
|
expect(softphone_1.default.client.answer).toHaveBeenCalled();
|
|
@@ -77,7 +117,9 @@ describe('Call', () => {
|
|
|
77
117
|
}));
|
|
78
118
|
it('Should not throw an error when terminating an established call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
79
119
|
const call = new call_1.default(sipCall, softphone_1.default);
|
|
120
|
+
// @ts-ignore: private method
|
|
80
121
|
call._sendAction(call_state_machine_1.Actions.MAKE_CALL);
|
|
122
|
+
// @ts-ignore: private method
|
|
81
123
|
call._sendAction(call_state_machine_1.Actions.REMOTLY_ACCEPTED);
|
|
82
124
|
yield call.hangup();
|
|
83
125
|
expect(softphone_1.default.client.hangup).toHaveBeenCalled();
|
|
@@ -66,6 +66,7 @@ exports.MESSAGE_TYPE_CHAT = 'message/TYPE_CHAT';
|
|
|
66
66
|
exports.MESSAGE_TYPE_SIGNAL = 'message/TYPE_SIGNAL';
|
|
67
67
|
exports.events = [exports.ON_USER_AGENT, exports.ON_REGISTERED, exports.ON_UNREGISTERED, exports.ON_PROGRESS, exports.ON_CALL_ACCEPTED, exports.ON_CALL_ANSWERED, exports.ON_CALL_INCOMING, exports.ON_CALL_OUTGOING, exports.ON_CALL_MUTED, exports.ON_CALL_UNMUTED, exports.ON_CALL_RESUMED, exports.ON_CALL_HELD, exports.ON_CALL_UNHELD, exports.ON_CAMERA_DISABLED, exports.ON_CALL_FAILED, exports.ON_CALL_ENDED, exports.ON_CALL_REJECTED, exports.ON_MESSAGE, exports.ON_REINVITE, exports.ON_TRACK, exports.ON_AUDIO_STREAM, exports.ON_VIDEO_STREAM, exports.ON_REMOVE_STREAM, exports.ON_SHARE_SCREEN_ENDED, exports.ON_TERMINATE_SOUND, exports.ON_PLAY_RING_SOUND, exports.ON_PLAY_INBOUND_CALL_SIGNAL_SOUND, exports.ON_PLAY_HANGUP_SOUND, exports.ON_PLAY_PROGRESS_SOUND, exports.ON_VIDEO_INPUT_CHANGE, exports.ON_SHARE_SCREEN_STARTED, exports.ON_CALL_ERROR, exports.ON_CHAT, exports.ON_SIGNAL, exports.ON_NETWORK_STATS, exports.ON_DISCONNECTED, exports.ON_EARLY_MEDIA];
|
|
68
68
|
const logger = IssueReporter_1.default.loggerFor('webrtc-phone');
|
|
69
|
+
// @deprecated: Use Wazo.Voice.Softphone instead
|
|
69
70
|
class WebRTCPhone extends Emitter_1.default {
|
|
70
71
|
constructor(client, audioOutputDeviceId, allowVideo = false, audioRingDeviceId) {
|
|
71
72
|
super();
|
|
@@ -1468,14 +1469,12 @@ class WebRTCPhone extends Emitter_1.default {
|
|
|
1468
1469
|
});
|
|
1469
1470
|
}
|
|
1470
1471
|
_createCallSession(sipSession, fromSession, extra = {}) {
|
|
1471
|
-
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
const number = identity ? identity.uri._normal.user : null;
|
|
1475
|
-
const { state, } = sipSession || {};
|
|
1472
|
+
const number = (0, sdp_1.getCallNumber)(sipSession);
|
|
1473
|
+
const displayName = (0, sdp_1.getCallDisplayName)(sipSession);
|
|
1474
|
+
const { state } = sipSession || {};
|
|
1476
1475
|
const sessionId = (0, sdp_1.getSipSessionId)(sipSession);
|
|
1477
1476
|
fromSession = fromSession || this.callSessions[sessionId];
|
|
1478
|
-
const callSession = new CallSession_1.default(Object.assign({ callId: fromSession && fromSession.callId, sipCallId: sessionId, sipStatus: state, displayName
|
|
1477
|
+
const callSession = new CallSession_1.default(Object.assign({ callId: fromSession && fromSession.callId, sipCallId: sessionId, sipStatus: state, displayName,
|
|
1479
1478
|
// @ts-ignore: date / number
|
|
1480
1479
|
startTime: fromSession ? fromSession.startTime : new Date(), creationTime: fromSession ? fromSession.creationTime : null, answerTime: fromSession ? fromSession.answerTime : null, endTime: fromSession ? fromSession.endTime : null, answered: state === session_state_1.SessionState.Established, paused: this.client.isCallHeld(sipSession), isCaller: fromSession ? fromSession.isCaller : 'incoming' in extra ? !extra.incoming : false, cameraEnabled: fromSession ? fromSession.isCameraEnabled() : this.client.hasVideo(sessionId), number, ringing: false, muted: fromSession ? fromSession.isMuted() : this.client.isAudioMuted(sipSession), videoMuted: fromSession ? fromSession.isVideoMuted() : false, recording: fromSession ? fromSession.isRecording() : false, recordingPaused: false, sipSession, conference: !!fromSession && fromSession.isConference() }, extra));
|
|
1481
1480
|
this.callSessions[sessionId] = callSession;
|
package/lib/simple/Phone.js
CHANGED
|
@@ -50,6 +50,7 @@ const logger = IssueReporter_1.default.loggerFor('simple-phone');
|
|
|
50
50
|
const sipLogger = IssueReporter_1.default.loggerFor('sip.js');
|
|
51
51
|
const protocolLogger = IssueReporter_1.default.loggerFor('sip');
|
|
52
52
|
const protocolDebugMessages = ['Received WebSocket text message:', 'Sending WebSocket message:'];
|
|
53
|
+
// @deprecated: Use Wazo.Voice.Softphone instead
|
|
53
54
|
class Phone extends Emitter_1.default {
|
|
54
55
|
constructor() {
|
|
55
56
|
super();
|
|
@@ -35,7 +35,7 @@ exports.EstablishedActions = {
|
|
|
35
35
|
};
|
|
36
36
|
const callMachine = (0, xstate_1.setup)({
|
|
37
37
|
guards: {
|
|
38
|
-
isRegistered: () => softphone_1.default.
|
|
38
|
+
isRegistered: () => softphone_1.default.state === softphone_state_machine_1.States.REGISTERED,
|
|
39
39
|
},
|
|
40
40
|
}).createMachine({
|
|
41
41
|
id: 'call',
|