apps/meteor/client/lib/voip/VoIPUser.ts
/**
* Class representing SIP UserAgent
* @remarks
* This class encapsulates all the details of sip.js and exposes
* a very simple functions and callback handlers to the outside world.
* This class thus abstracts user from Browser specific media details as well as
* SIP specific protocol details.
*/
import type {
CallStates,
ConnectionState,
ICallerInfo,
IQueueMembershipSubscription,
SignalingSocketEvents,
SocketEventKeys,
IMediaStreamRenderer,
VoIPUserConfiguration,
VoIpCallerInfo,
IState,
VoipEvents,
} from '@rocket.chat/core-typings';
import { Operation, UserState, WorkflowTypes } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { UserAgentOptions, InvitationAcceptOptions, Session, SessionInviteOptions } from 'sip.js';
import { UserAgent, Invitation, SessionState, Registerer, RequestPendingError, Inviter } from 'sip.js';
import type { OutgoingByeRequest, OutgoingRequestDelegate } from 'sip.js/lib/core';
import { URI } from 'sip.js/lib/core';
import type { SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web';
import { SessionDescriptionHandler } from 'sip.js/lib/platform/web';
import { toggleMediaStreamTracks } from './Helper';
import LocalStream from './LocalStream';
import { QueueAggregator } from './QueueAggregator';
import RemoteStream from './RemoteStream';
export class VoIPUser extends Emitter<VoipEvents> {
state: IState = {
isReady: false,
enableVideo: false,
};
private remoteStream: RemoteStream | undefined;
userAgentOptions: UserAgentOptions = {};
userAgent: UserAgent | undefined;
registerer: Registerer | undefined;
mediaStreamRendered?: IMediaStreamRenderer;
private _connectionState: ConnectionState = 'INITIAL';
private _held = false;
private mode: WorkflowTypes;
private queueInfo: QueueAggregator;
private connectionRetryCount;
private stop;
private networkEmitter: Emitter<SignalingSocketEvents>;
private offlineNetworkHandler: () => void;
private onlineNetworkHandler: () => void;
private optionsKeepaliveInterval = 5;
private optionsKeepAliveDebounceTimeInSec = 5;
private attemptRegistration = false;
protected session: Session | undefined;
protected _callState: CallStates = 'INITIAL';
protected _callerInfo: ICallerInfo | undefined;
protected _userState: UserState = UserState.IDLE;
protected _opInProgress: Operation = Operation.OP_NONE;
get operationInProgress(): Operation {
return this._opInProgress;
}
get userState(): UserState | undefined {
return this._userState;
}
constructor(private readonly config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer) {
super();
this.mediaStreamRendered = mediaRenderer;
this.networkEmitter = new Emitter<SignalingSocketEvents>();
this.connectionRetryCount = this.config.connectionRetryCount;
this.stop = false;
this.onlineNetworkHandler = this.onNetworkRestored.bind(this);
this.offlineNetworkHandler = this.onNetworkLost.bind(this);
}
/**
* Configures and initializes sip.js UserAgent
* call gets established.
* @remarks
* This class configures transport properties such as websocket url, passed down in config,
* sets up ICE servers,
* SIP UserAgent options such as userName, Password, URI.
* Once initialized, it starts the userAgent.
*/
async init(): Promise<void> {
const sipUri = `sip:${this.config.authUserName}@${this.config.sipRegistrarHostnameOrIP}`;
const transportOptions = {
server: this.config.webSocketURI,
connectionTimeout: 100, // Replace this with config
keepAliveInterval: 20,
// traceSip: true,
};
const sdpFactoryOptions = {
iceGatheringTimeout: 10,
peerConnectionConfiguration: {
iceServers: this.config.iceServers,
},
};
this.userAgentOptions = {
delegate: {
onInvite: async (invitation: Invitation): Promise<void> => {
await this.handleIncomingCall(invitation);
},
},
authorizationPassword: this.config.authPassword,
authorizationUsername: this.config.authUserName,
uri: UserAgent.makeURI(sipUri),
transportOptions,
sessionDescriptionHandlerFactoryOptions: sdpFactoryOptions,
logConfiguration: false,
logLevel: 'error',
};
this.userAgent = new UserAgent(this.userAgentOptions);
this.userAgent.transport.isConnected();
this._opInProgress = Operation.OP_CONNECT;
try {
this.registerer = new Registerer(this.userAgent);
this.userAgent.transport.onConnect = this.onConnected.bind(this);
this.userAgent.transport.onDisconnect = this.onDisconnected.bind(this);
window.addEventListener('online', this.onlineNetworkHandler);
window.addEventListener('offline', this.offlineNetworkHandler);
await this.userAgent.start();
if (this.config.enableKeepAliveUsingOptionsForUnstableNetworks) {
this.startOptionsPingForUnstableNetworks();
}
} catch (error) {
this._connectionState = 'ERROR';
throw error;
}
}
async onConnected(): Promise<void> {
this._connectionState = 'SERVER_CONNECTED';
this.state.isReady = true;
this.sendOptions();
this.networkEmitter.emit('connected');
/**
* Re-registration post network recovery should be attempted
* if it was previously registered or incall/onhold
*/
if (this.registerer && this.callState !== 'INITIAL') {
this.attemptRegistration = true;
}
}
onDisconnected(error: any): void {
this._connectionState = 'SERVER_DISCONNECTED';
this._opInProgress = Operation.OP_NONE;
this.networkEmitter.emit('disconnected');
if (error) {
this.networkEmitter.emit('connectionerror', error);
this.state.isReady = false;
/**
* Signalling socket reconnection should be attempted assuming
* that the disconnect happened from the remote side or due to sleep
* In case of remote side disconnection, if config.connectionRetryCount is -1,
* attemptReconnection attempts continuously. Else stops after |config.connectionRetryCount|
*
*/
// this.attemptReconnection();
this.attemptReconnection(0, false);
}
}
onNetworkRestored(): void {
this.networkEmitter.emit('localnetworkonline');
if (this._connectionState === 'WAITING_FOR_NETWORK') {
/**
* Signalling socket reconnection should be attempted when online event handler
* gets notified.
* Important thing to note is that the second parameter |checkRegistration| = true passed here
* because after the network recovery and after reconnecting to the server,
* the transport layer of SIPUA does not call onConnected. So by passing |checkRegistration = true |
* the code will check if the endpoint was previously registered before the disconnection.
* If such is the case, it will first unregister and then re-register.
* */
this.attemptReconnection();
if (this.registerer && this.callState !== 'INITIAL') {
this.attemptRegistration = true;
}
}
}
onNetworkLost(): void {
this.networkEmitter.emit('localnetworkoffline');
this._connectionState = 'WAITING_FOR_NETWORK';
}
get userConfig(): VoIPUserConfiguration {
return this.config;
}
get callState(): CallStates {
return this._callState;
}
get connectionState(): ConnectionState {
return this._connectionState;
}
get callerInfo(): VoIpCallerInfo {
if (
this.callState === 'IN_CALL' ||
this.callState === 'OFFER_RECEIVED' ||
this.callState === 'ON_HOLD' ||
this.callState === 'OFFER_SENT'
) {
if (!this._callerInfo) {
throw new Error('[VoIPUser callerInfo] invalid state');
}
return {
state: this.callState,
caller: this._callerInfo,
userState: this._userState,
};
}
return {
state: this.callState,
userState: this._userState,
};
}
/* Media Stream functions begin */
/** The local media stream. Undefined if call not answered. */
get localMediaStream(): MediaStream | undefined {
const sdh = this.session?.sessionDescriptionHandler;
if (!sdh) {
return undefined;
}
if (!(sdh instanceof SessionDescriptionHandler)) {
throw new Error('Session description handler not instance of web SessionDescriptionHandler');
}
return sdh.localMediaStream;
}
/* Media Stream functions end */
/* OutgoingRequestDelegate methods begin */
onRegistrationRequestAccept(): void {
if (this._opInProgress === Operation.OP_REGISTER) {
this._callState = 'REGISTERED';
this.emit('registered');
this.emit('stateChanged');
}
if (this._opInProgress === Operation.OP_UNREGISTER) {
this._callState = 'UNREGISTERED';
this.emit('unregistered');
this.emit('stateChanged');
}
}
onRegistrationRequestReject(error: any): void {
if (this._opInProgress === Operation.OP_REGISTER) {
this.emit('registrationerror', error);
}
if (this._opInProgress === Operation.OP_UNREGISTER) {
this.emit('unregistrationerror', error);
}
}
/* OutgoingRequestDelegate methods end */
private async handleIncomingCall(invitation: Invitation): Promise<void> {
if (this.callState === 'REGISTERED') {
this._opInProgress = Operation.OP_PROCESS_INVITE;
this._callState = 'OFFER_RECEIVED';
this._userState = UserState.UAS;
this.session = invitation;
this.setupSessionEventHandlers(invitation);
const callerInfo: ICallerInfo = {
callerId: invitation.remoteIdentity.uri.user ? invitation.remoteIdentity.uri.user : '',
callerName: invitation.remoteIdentity.displayName,
host: invitation.remoteIdentity.uri.host,
};
this._callerInfo = callerInfo;
this.emit('incomingcall', callerInfo);
this.emit('stateChanged');
return;
}
await invitation.reject();
}
/**
* Sets up an listener handler for handling session's state change
* @remarks
* Called for setting up various state listeners. These listeners will
* decide the next action to be taken when the session state changes.
* e.g when session.state changes from |Establishing| to |Established|
* one must set up local and remote media rendering.
*
* This class handles such session state changes and takes necessary actions.
*/
protected setupSessionEventHandlers(session: Session): void {
this.session?.stateChange.addListener((state: SessionState) => {
if (this.session !== session) {
return; // if our session has changed, just return
}
switch (state) {
case SessionState.Initial:
break;
case SessionState.Establishing:
this.emit('ringing', { userState: this._userState, callInfo: this._callerInfo });
break;
case SessionState.Established:
if (this._userState === UserState.UAC) {
/**
* We need to decide about user-state ANSWER-RECEIVED for outbound.
* This state is there for the symmetry of ANSWER-SENT.
* ANSWER-SENT occurs when there is incoming invite. So then the UA
* accepts a call, it sends the answer and state becomes ANSWER-SENT.
* The call gets established only when the remote party sends ACK.
*
* But in case of UAC where the invite is sent out, there is no intermediate
* state where the UA can be in ANSWER-RECEIVED. As soon this UA receives the answer,
* it sends ack and changes the SessionState to established.
*
* So we do not have an actual state transitions from ANSWER-RECEIVED to IN-CALL.
*
* Nevertheless, this state is just added to maintain the symmetry. This can be safely removed.
*
* */
this._callState = 'ANSWER_RECEIVED';
}
this._opInProgress = Operation.OP_NONE;
this.setupRemoteMedia();
this._callState = 'IN_CALL';
this.emit('callestablished', { userState: this._userState, callInfo: this._callerInfo });
this.emit('stateChanged');
break;
case SessionState.Terminating:
// fall through
case SessionState.Terminated:
this.session = undefined;
this._callState = 'REGISTERED';
this._opInProgress = Operation.OP_NONE;
this._userState = UserState.IDLE;
this.emit('callterminated');
this.remoteStream?.clear();
this.emit('stateChanged');
break;
default:
throw new Error('Unknown session state.');
}
});
}
onTrackAdded(_event: any): void {
console.log('onTrackAdded');
}
onTrackRemoved(_event: any): void {
console.log('onTrackRemoved');
}
/**
* Carries out necessary steps for rendering remote media whe
* call gets established.
* @remarks
* Sets up Stream class and plays the stream on given Media element/
* Also sets up various event handlers.
*/
private setupRemoteMedia(): any {
if (!this.session) {
throw new Error('Session does not exist.');
}
const sdh = this.session?.sessionDescriptionHandler;
if (!sdh) {
return undefined;
}
if (!(sdh instanceof SessionDescriptionHandler)) {
throw new Error('Session description handler not instance of web SessionDescriptionHandler');
}
const remoteStream = sdh.remoteMediaStream;
if (!remoteStream) {
throw new Error('Remote media stream is undefined.');
}
this.remoteStream = new RemoteStream(remoteStream);
const mediaElement = this.mediaStreamRendered?.remoteMediaElement;
if (mediaElement) {
this.remoteStream.init(mediaElement);
this.remoteStream.onTrackAdded(this.onTrackAdded.bind(this));
this.remoteStream.onTrackRemoved(this.onTrackRemoved.bind(this));
this.remoteStream.play();
}
}
/**
* Handles call mute-unmute
*/
private async handleMuteUnmute(muteState: boolean): Promise<void> {
const { session } = this;
if (this._held === muteState) {
return Promise.resolve();
}
if (!session) {
throw new Error('Session not found');
}
const sessionDescriptionHandler = this.session?.sessionDescriptionHandler;
if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) {
throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
}
const options: SessionInviteOptions = {
requestDelegate: {
onAccept: (): void => {
this._held = muteState;
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
},
onReject: (): void => {
this.emit('muteerror');
},
},
};
const { peerConnection } = sessionDescriptionHandler;
if (!peerConnection) {
throw new Error('Peer connection closed.');
}
return this.session
?.invite(options)
.then(() => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
})
.catch((error: Error) => {
if (error instanceof RequestPendingError) {
console.error(`[${this.session?.id}] A mute request is already in progress.`);
}
this.emit('muteerror');
throw error;
});
}
/**
* Handles call hold-unhold
*/
private async handleHoldUnhold(holdState: boolean): Promise<void> {
const { session } = this;
if (this._held === holdState) {
return Promise.resolve();
}
if (!session) {
throw new Error('Session not found');
}
const sessionDescriptionHandler = this.session?.sessionDescriptionHandler;
if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) {
throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
}
const options: SessionInviteOptions = {
requestDelegate: {
onAccept: (): void => {
this._held = holdState;
this._callState = holdState ? 'ON_HOLD' : 'IN_CALL';
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
this._callState === 'ON_HOLD' ? this.emit('hold') : this.emit('unhold');
this.emit('stateChanged');
},
onReject: (): void => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
this.emit('holderror');
},
},
};
// Session properties used to pass options to the SessionDescriptionHandler:
//
// 1) Session.sessionDescriptionHandlerOptions
// SDH options for the initial INVITE transaction.
// - Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
// - May be set directly at anytime.
// - May optionally be set via constructor option.
// - May optionally be set via options passed to Inviter.invite() or Invitation.accept().
//
// 2) Session.sessionDescriptionHandlerOptionsReInvite
// SDH options for re-INVITE transactions.
// - Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
// - May be set directly at anytime.
// - May optionally be set via constructor option.
// - May optionally be set via options passed to Session.invite().
const sessionDescriptionHandlerOptions = session.sessionDescriptionHandlerOptionsReInvite as SessionDescriptionHandlerOptions;
sessionDescriptionHandlerOptions.hold = holdState;
session.sessionDescriptionHandlerOptionsReInvite = sessionDescriptionHandlerOptions;
const { peerConnection } = sessionDescriptionHandler;
if (!peerConnection) {
throw new Error('Peer connection closed.');
}
return this.session
?.invite(options)
.then(() => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
})
.catch((error: Error) => {
if (error instanceof RequestPendingError) {
console.error(`[${this.session?.id}] A hold request is already in progress.`);
}
this.emit('holderror');
throw error;
});
}
static async create(config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer): Promise<VoIPUser> {
const voip = new VoIPUser(config, mediaRenderer);
await voip.init();
return voip;
}
/**
* Sends SIP OPTIONS message to asterisk
*
* There is an interesting problem that happens with Asterisk.
* After websocket connection succeeds and if there is no SIP
* message goes in 30 seconds, asterisk disconnects the socket.
*
* If any SIP message goes before 30 seconds, asterisk holds the connection.
* This problem could be solved in multiple ways. One is that
* whenever disconnect happens make sure that the socket is connected back using
* this.userAgent.reconnect() method. But this is expensive as it does connect-disconnect
* every 30 seconds till we send register message.
*
* Another approach is to send SIP OPTIONS just to tell server that
* there is a UA using this socket. This is implemented below
*/
sendOptions(outgoingRequestDelegate?: OutgoingRequestDelegate): void {
const uri = new URI('sip', this.config.authUserName, this.config.sipRegistrarHostnameOrIP);
const outgoingMessage = this.userAgent?.userAgentCore.makeOutgoingRequestMessage('OPTIONS', uri, uri, uri, {});
if (outgoingMessage) {
this.userAgent?.userAgentCore.request(outgoingMessage, outgoingRequestDelegate);
}
}
/**
* Public method called from outside to register the SIP UA with call server.
* @remarks
*/
register(): void {
this._opInProgress = Operation.OP_REGISTER;
this.registerer?.register({
requestDelegate: {
onAccept: this.onRegistrationRequestAccept.bind(this),
onReject: this.onRegistrationRequestReject.bind(this),
},
});
}
/**
* Public method called from outside to unregister the SIP UA.
* @remarks
*/
unregister(): void {
this._opInProgress = Operation.OP_UNREGISTER;
this.registerer?.unregister({
all: true,
requestDelegate: {
onAccept: this.onRegistrationRequestAccept.bind(this),
onReject: this.onRegistrationRequestReject.bind(this),
},
});
}
/**
* Public method called from outside to accept incoming call.
* @remarks
*/
async acceptCall(mediaRenderer: IMediaStreamRenderer): Promise<void> {
if (mediaRenderer) {
this.mediaStreamRendered = mediaRenderer;
}
// Call state must be in offer_received.
if (this._callState === 'OFFER_RECEIVED' && this._opInProgress === Operation.OP_PROCESS_INVITE) {
this._callState = 'ANSWER_SENT';
// Something is wrong, this session is not an instance of INVITE
if (!(this.session instanceof Invitation)) {
throw new Error('Session not instance of Invitation.');
}
/**
* It is important to decide when to add video option to the outgoing offer.
* This would matter when the reinvite goes out (In case of hold/unhold)
* This was added because there were failures in hold-unhold.
* The scenario was that if this client does hold-unhold first, and remote client does
* later, remote client goes in inconsistent state and hold-unhold does not work
* Where as if the remote client does hold-unhold first, this client can do it any number
* of times.
*
* Logic below works as follows
* Local video settings = true, incoming invite has video mline = false -> Any offer = audiovideo/ answer = audioonly
* Local video settings = true, incoming invite has video mline = true -> Any offer = audiovideo/ answer = audiovideo
* Local video settings = false, incoming invite has video mline = false -> Any offer = audioonly/ answer = audioonly
* Local video settings = false, incoming invite has video mline = true -> Any offer = audioonly/ answer = audioonly
*
*/
let videoInvite = !!this.config.enableVideo;
const { body } = this.session;
if (body && body.indexOf('m=video') === -1) {
videoInvite = false;
}
const invitationAcceptOptions: InvitationAcceptOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: !!this.config.enableVideo && videoInvite,
},
},
};
return this.session.accept(invitationAcceptOptions);
}
throw new Error('Something went wrong');
}
/* Helper routines for checking call actions BEGIN */
private canRejectCall(): boolean {
return ['OFFER_RECEIVED', 'OFFER_SENT'].includes(this._callState);
}
private canEndOrHoldCall(): boolean {
return ['ANSWER_SENT', 'ANSWER_RECEIVED', 'IN_CALL', 'ON_HOLD', 'OFFER_SENT'].includes(this._callState);
}
/* Helper routines for checking call actions END */
/**
* Public method called from outside to reject a call.
* @remarks
*/
rejectCall(): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (!this.canRejectCall()) {
throw new Error(`Incorrect call State = ${this.callState}`);
}
if (!(this.session instanceof Invitation)) {
throw new Error('Session not instance of Invitation.');
}
return this.session.reject();
}
/**
* Public method called from outside to end a call.
* @remarks
*/
async endCall(): Promise<OutgoingByeRequest | void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (!this.canEndOrHoldCall()) {
throw new Error(`Incorrect call State = ${this.callState}`);
}
// When call ends, force state to be revisited
this.emit('stateChanged');
switch (this.session.state) {
case SessionState.Initial:
if (this.session instanceof Invitation) {
return this.session.reject();
}
throw new Error('Session not instance of Invitation.');
case SessionState.Establishing:
if (this.session instanceof Invitation) {
return this.session.reject();
}
if (this.session instanceof Inviter) {
return this.session.cancel();
}
throw new Error('Session not instance of Invitation.');
case SessionState.Established:
return this.session.bye();
case SessionState.Terminating:
break;
case SessionState.Terminated:
break;
default:
throw new Error('Unknown state');
}
}
/**
* Public method called from outside to mute the call.
* @remarks
*/
async muteCall(muteState: boolean): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (this._callState !== 'IN_CALL') {
throw new Error(`Incorrect call State = ${this.callState}`);
}
this.handleMuteUnmute(muteState);
}
/**
* Public method called from outside to hold the call.
* @remarks
*/
async holdCall(holdState: boolean): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (!this.canEndOrHoldCall()) {
throw new Error(`Incorrect call State = ${this.callState}`);
}
this.handleHoldUnhold(holdState);
}
/* CallEventDelegate implementation end */
isReady(): boolean {
return this.state.isReady;
}
/**
* This function allows to change the media renderer media elements.
*/
switchMediaRenderer(mediaRenderer: IMediaStreamRenderer): void {
if (this.remoteStream) {
this.mediaStreamRendered = mediaRenderer;
this.remoteStream.init(mediaRenderer.remoteMediaElement);
this.remoteStream.onTrackAdded(this.onTrackAdded.bind(this));
this.remoteStream.onTrackRemoved(this.onTrackRemoved.bind(this));
this.remoteStream.play();
}
}
setWorkflowMode(mode: WorkflowTypes): void {
this.mode = mode;
if (mode === WorkflowTypes.CONTACT_CENTER_USER) {
this.queueInfo = new QueueAggregator();
}
}
setMembershipSubscription(subscription: IQueueMembershipSubscription): void {
if (this.mode !== WorkflowTypes.CONTACT_CENTER_USER) {
return;
}
this.queueInfo?.setMembership(subscription);
}
getAggregator(): QueueAggregator {
return this.queueInfo;
}
getRegistrarState(): string | undefined {
return this.registerer?.state.toString().toLocaleLowerCase();
}
clear(): void {
this._opInProgress = Operation.OP_CLEANUP;
/** Socket reconnection is attempted when the socket is disconnected with some error.
* While disconnecting, if there is any socket error, there should be no reconnection attempt.
* So when userAgent.stop() is called which closes the sockets, it should be made sure that
* if the socket is disconnected with error, connection attempts are not started or
* if there are any previously ongoing attempts, they should be terminated.
* flag attemptReconnect is used for ensuring this.
*/
this.stop = true;
this.userAgent?.stop();
this.registerer?.dispose();
this._connectionState = 'STOP';
if (this.userAgent) {
this.userAgent.transport.onConnect = undefined;
this.userAgent.transport.onDisconnect = undefined;
window.removeEventListener('online', this.onlineNetworkHandler);
window.removeEventListener('offline', this.offlineNetworkHandler);
}
}
onNetworkEvent(event: SocketEventKeys, handler: () => void): void {
this.networkEmitter.on(event, handler);
}
offNetworkEvent(event: SocketEventKeys, handler: () => void): void {
this.networkEmitter.off(event, handler);
}
/**
* Connection is lost in 3 ways
* 1. When local network is lost (Router is disconnected, switching networks, devtools->network->offline)
* In this case, the SIP.js's transport layer does not detect the disconnection. Hence, it does not
* call |onDisconnect|. To detect this kind of disconnection, window event listeners have been added.
* These event listeners would be get called when the browser detects that network is offline or online.
* When the network is restored, the code tries to reconnect. The useragent.transport "does not" generate the
* onconnected event in this case as well. so onlineNetworkHandler calls attemptReconnection.
* Which calls attemptRegistrationPostRecovery based on correct state. attemptRegistrationPostRecovery first tries to
* unregister and then re-register.
* Important note : We use the event listeners using bind function object offlineNetworkHandler and onlineNetworkHandler
* It is done so because the same event handlers need to be used for removeEventListener, which becomes impossible
* if done inline.
*
* 2. Computer goes to sleep. In this case onDisconnect is triggered. The code tries to reconnect but cant go ahead
* as it goes to sleep. On waking up, The attemptReconnection gets executed, connection is completed.
* In this case, it generates onConnected event. In this onConnected event it calls attemptRegistrationPostRecovery
*
* 3. When Asterisk disconnects all the endpoints either because it crashes or restarted,
* As soon as the agent successfully connects to asterisk, it should re-register
*
* Retry count :
* connectionRetryCount is the parameter called |Retry Count| in
* Administration -> Call Center -> Server configuration -> Retry count.
* The retry is implemented with backoff, maxbackoff = 8 seconds.
* For continuous retries (In case Asterisk restart happens) Set this parameter to -1.
*
* Important to note is how attemptRegistrationPostRecovery is called. In case of
* the router connection loss or while switching the networks,
* there is no disconnect and connect event from the transport layer of the userAgent.
* So in this case, when the connection is successful after reconnect, the code should try to re-register by calling
* attemptRegistrationPostRecovery.
* In case of computer waking from sleep or asterisk getting restored, connect and disconnect events are generated.
* In this case, re-registration should be triggered (by calling) only when onConnected gets called and not otherwise.
*/
async attemptReconnection(reconnectionAttempt = 0, checkRegistration = false): Promise<void> {
const reconnectionAttempts = this.connectionRetryCount;
this._connectionState = 'SERVER_RECONNECTING';
if (!this.userAgent) {
return;
}
if (this.stop) {
return;
}
// reconnectionAttempts == -1 then keep continuously trying
if (reconnectionAttempts !== -1 && reconnectionAttempt > reconnectionAttempts) {
this._connectionState = 'ERROR';
return;
}
const reconnectionDelay = Math.pow(2, reconnectionAttempt % 4);
console.error(`Attempting to reconnect with backoff due to network loss. Backoff time [${reconnectionDelay}]`);
setTimeout(() => {
if (this.stop) {
return;
}
if (this._connectionState === 'SERVER_CONNECTED') {
return;
}
this.userAgent
?.reconnect()
.then(() => {
this._connectionState = 'SERVER_CONNECTED';
})
.catch(() => {
this.attemptReconnection(++reconnectionAttempt, checkRegistration);
});
}, reconnectionDelay * 1000);
}
async attemptPostRecoveryRoutine(): Promise<void> {
/**
* It might happen that the whole network loss can happen
* while there is ongoing call. In that case, we want to maintain
* the call.
*
* So after re-registration, it should remain in the same state.
* */
this.sendOptions({
onAccept: (): void => {
this.attemptPostRecoveryRegistrationRoutine();
},
onReject: (error: unknown): void => {
console.error(`[${error}] Failed to do options in attemptPostRecoveryRoutine()`);
},
});
}
async sendKeepAliveAndWaitForResponse(withDebounce = false): Promise<boolean> {
const promise = new Promise<boolean>((resolve, reject) => {
let keepAliveAccepted = false;
let responseWaitTime = this.optionsKeepaliveInterval / 2;
if (withDebounce) {
responseWaitTime += this.optionsKeepAliveDebounceTimeInSec;
}
this.sendOptions({
onAccept: (): void => {
keepAliveAccepted = true;
},
onReject: (_error: unknown): void => {
console.error('Failed to do options.');
},
});
setTimeout(async () => {
if (!keepAliveAccepted) {
reject(false);
} else {
if (this.attemptRegistration) {
this.attemptPostRecoveryRoutine();
this.attemptRegistration = false;
}
resolve(true);
}
}, responseWaitTime * 1000);
});
return promise;
}
async startOptionsPingForUnstableNetworks(): Promise<void> {
setTimeout(async () => {
if (!this.userAgent || this.stop) {
return;
}
if (this._connectionState !== 'SERVER_RECONNECTING') {
let isConnected = false;
try {
await this.sendKeepAliveAndWaitForResponse();
isConnected = true;
} catch (e) {
console.error(`[${e}] Failed to do options ping.`);
} finally {
// Send event only if it's a "change" on the status (avoid unnecessary event flooding)
!isConnected && this.networkEmitter.emit('disconnected');
isConnected && this.networkEmitter.emit('connected');
}
}
// Each seconds check if the network can reach asterisk. If not, try to reconnect
this.startOptionsPingForUnstableNetworks();
}, this.optionsKeepaliveInterval * 1000);
}
async attemptPostRecoveryRegistrationRoutine(): Promise<void> {
/**
* It might happen that the whole network loss can happen
* while there is ongoing call. In that case, we want to maintain
* the call.
*
* So after re-registration, it should remain in the same state.
* */
const promise = new Promise<void>((_resolve, _reject) => {
this.registerer?.unregister({
all: true,
requestDelegate: {
onAccept: (): void => {
_resolve();
},
onReject: (error): void => {
console.error(`[${error}] While unregistering after recovery`);
this.emit('unregistrationerror', error);
_reject('Error in Unregistering');
},
},
});
});
try {
await promise;
} catch (error) {
console.error(`[${error}] While waiting for unregister promise`);
}
this.registerer?.register({
requestDelegate: {
onReject: (error): void => {
this._callState = 'UNREGISTERED';
this.emit('registrationerror', error);
this.emit('stateChanged');
},
},
});
}
async changeAudioInputDevice(constraints: MediaStreamConstraints): Promise<boolean> {
if (!this.session) {
console.warn('changeAudioInputDevice() : No session. Returning');
return false;
}
const newStream = await LocalStream.requestNewStream(constraints, this.session);
if (!newStream) {
console.warn('changeAudioInputDevice() : Unable to get local stream. Returning');
return false;
}
const { peerConnection } = this.session?.sessionDescriptionHandler as SessionDescriptionHandler;
if (!peerConnection) {
console.warn('changeAudioInputDevice() : No peer connection. Returning');
return false;
}
LocalStream.replaceTrack(peerConnection, newStream, 'audio');
return true;
}
// Commenting this as Video Configuration is not part of the scope for now
// async changeVideoInputDevice(selectedVideoDevices: IDevice): Promise<boolean> {
// if (!this.session) {
// console.warn('changeVideoInputDevice() : No session. Returning');
// return false;
// }
// if (!this.config.enableVideo || this.deviceManager.hasVideoInputDevice()) {
// console.warn('changeVideoInputDevice() : Unable change video device. Returning');
// return false;
// }
// this.deviceManager.changeVideoInputDevice(selectedVideoDevices);
// const newStream = await LocalStream.requestNewStream(this.deviceManager.getConstraints('video'), this.session);
// if (!newStream) {
// console.warn('changeVideoInputDevice() : Unable to get local stream. Returning');
// return false;
// }
// const { peerConnection } = this.session?.sessionDescriptionHandler as SessionDescriptionHandler;
// if (!peerConnection) {
// console.warn('changeVideoInputDevice() : No peer connection. Returning');
// return false;
// }
// LocalStream.replaceTrack(peerConnection, newStream, 'video');
// return true;
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async makeCallURI(_callee: string, _mediaRenderer?: IMediaStreamRenderer): Promise<void> {
throw new Error('Not implemented');
}
async makeCall(_calleeNumber: string): Promise<void> {
throw new Error('Not implemented');
}
}