UNPKG

kod-temp

Version:

Workano Communications SDK

1,257 lines 89.4 kB
/* eslint-disable class-methods-use-this, no-param-reassign, max-classes-per-file, no-underscore-dangle */ /* global window, document, navigator */ import 'webrtc-adapter'; import { UserAgentState } from 'sip.js/lib/api/user-agent-state'; import { Parser } from 'sip.js/lib/core/messages/parser'; import { C } from 'sip.js/lib/core/messages/methods/constants'; import { URI } from 'sip.js/lib/grammar/uri'; import { UserAgent } from 'sip.js/lib/api/user-agent'; import { holdModifier, stripVideo } from 'sip.js/lib/platform/web/modifiers/modifiers'; import { Messager } from 'sip.js/lib/api/messager'; import { RegistererState } from 'sip.js/lib/api/registerer-state'; import { SessionState } from 'sip.js/lib/api/session-state'; import { TransportState } from 'sip.js/lib/api/transport-state'; import { defaultPeerConnectionConfiguration } from 'sip.js/lib/platform/web/session-description-handler/peer-connection-configuration-default'; import getStats from 'getstats'; import { Inviter, Invitation, Registerer } from 'sip.js/lib/api'; import WorkanoSessionDescriptionHandler, { workanoMediaStreamFactory } from './lib/WorkanoSessionDescriptionHandler'; import Emitter from './utils/Emitter'; import ApiClient from './api-client'; import IssueReporter from './service/IssueReporter'; import Heartbeat from './utils/Heartbeat'; import { getVideoDirection, hasAnActiveVideo } from './utils/sdp'; import { lastIndexOf } from './utils/array'; // We need to replace 0.0.0.0 to 127.0.0.1 in the sdp to avoid MOH during a createOffer. export const replaceLocalIpModifier = (description) => Promise.resolve({ ...JSON.parse(JSON.stringify(description)), sdp: description.sdp.replace('c=IN IP4 0.0.0.0', 'c=IN IP4 127.0.0.1'), }); const SIP_ID_LENGTH = 36; const DEFAULT_ICE_TIMEOUT = 3000; const SEND_STATS_DELAY = 5000; const states = ['STATUS_NULL', 'STATUS_NEW', 'STATUS_CONNECTING', 'STATUS_CONNECTED', 'STATUS_COMPLETED']; const logger = IssueReporter ? IssueReporter.loggerFor('webrtc-client') : console; const statsLogger = IssueReporter ? IssueReporter.loggerFor('webrtc-stats') : console; // events const REGISTERED = 'registered'; const UNREGISTERED = 'unregistered'; const REGISTRATION_FAILED = 'registrationFailed'; const INVITE = 'invite'; const CONNECTED = 'connected'; const DISCONNECTED = 'disconnected'; const TRANSPORT_ERROR = 'transportError'; const MESSAGE = 'message'; const ACCEPTED = 'accepted'; const REJECTED = 'rejected'; const ON_TRACK = 'onTrack'; const ON_PROGRESS = 'onProgress'; const ON_EARLY_MEDIA = 'onEarlyMedia'; const ON_REINVITE = 'reinvite'; const ON_ERROR = 'onError'; const ON_SCREEN_SHARING_REINVITE = 'onScreenSharingReinvite'; const ON_NETWORK_STATS = 'onNetworkStats'; const ON_DISCONNECTED = 'onDisconnected'; export const events = [REGISTERED, UNREGISTERED, REGISTRATION_FAILED, INVITE]; export const transportEvents = [CONNECTED, DISCONNECTED, TRANSPORT_ERROR, MESSAGE]; export class CanceledCallError extends Error { } const MAX_REGISTER_TRIES = 5; // setting a 24hr timeout and letting the backend define the actual value const NO_ANSWER_TIMEOUT = 60 * 60 * 24; // in seconds export default class WebRTCClient extends Emitter { clientId; config; uaConfigOverrides; userAgent; registerer; hasAudio; audio; audioElements; video; audioStreams; audioOutputDeviceId; audioOutputVolume; heldSessions; connectionPromise; _boundOnHeartbeat; heartbeat; heartbeatTimeoutCb; heartbeatCb; statsIntervals; sipSessions; conferences; skipRegister; networkMonitoringInterval; sessionNetworkStats; forceClosed; // sugar ON_USER_AGENT; REGISTERED; UNREGISTERED; REGISTRATION_FAILED; INVITE; CONNECTED; DISCONNECTED; TRANSPORT_ERROR; MESSAGE; ACCEPTED; REJECTED; ON_TRACK; ON_REINVITE; ON_ERROR; ON_SCREEN_SHARING_REINVITE; ON_NETWORK_STATS; ON_EARLY_MEDIA; ON_PROGRESS; ON_DISCONNECTED; static isAPrivateIp(ip) { const regex = /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\..*/; return regex.exec(ip) == null; } static getIceServers(ip) { if (WebRTCClient.isAPrivateIp(ip)) { return [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun4.l.google.com:19302'], }]; } return []; } constructor(config, session, uaConfigOverrides) { super(); // For debug purpose this.clientId = Math.ceil(Math.random() * 1000); logger.info('sdk webrtc constructor', { clientId: this.clientId }); this.uaConfigOverrides = uaConfigOverrides; this.config = config; this.skipRegister = config.skipRegister; this._buildConfig(config, session).then((newConfig) => { this.config = newConfig; this.userAgent = this.createUserAgent(uaConfigOverrides); }); this.audioOutputDeviceId = config.audioDeviceOutput; this.audioOutputVolume = config.audioOutputVolume || 1; if (config.media) { this.configureMedia(config.media); this.setMediaConstraints({ audio: config.media.audio, video: config.media.video, }); } this.heldSessions = {}; this.statsIntervals = {}; this.connectionPromise = null; this.sipSessions = {}; this.conferences = {}; this.networkMonitoringInterval = {}; this.sessionNetworkStats = {}; this.forceClosed = false; this._boundOnHeartbeat = this._onHeartbeat.bind(this); this.heartbeat = new Heartbeat(config.heartbeatDelay, config.heartbeatTimeout, config.maxHeartbeats); this.heartbeat.setSendHeartbeat(this.pingServer.bind(this)); this.heartbeat.setOnHeartbeatTimeout(this._onHeartbeatTimeout.bind(this)); // sugar this.REGISTERED = REGISTERED; this.UNREGISTERED = UNREGISTERED; this.REGISTRATION_FAILED = REGISTRATION_FAILED; this.INVITE = INVITE; this.CONNECTED = CONNECTED; this.DISCONNECTED = DISCONNECTED; this.TRANSPORT_ERROR = TRANSPORT_ERROR; this.MESSAGE = MESSAGE; this.ACCEPTED = ACCEPTED; this.REJECTED = REJECTED; this.ON_TRACK = ON_TRACK; this.ON_REINVITE = ON_REINVITE; this.ON_ERROR = ON_ERROR; this.ON_SCREEN_SHARING_REINVITE = ON_SCREEN_SHARING_REINVITE; this.ON_NETWORK_STATS = ON_NETWORK_STATS; this.ON_DISCONNECTED = ON_DISCONNECTED; this.ON_EARLY_MEDIA = ON_EARLY_MEDIA; this.ON_PROGRESS = ON_PROGRESS; } configureMedia(media) { this.hasAudio = !!media.audio; this.audioStreams = {}; this.audioElements = {}; } setMediaConstraints(media) { this.video = media.video; this.audio = media.audio; } createUserAgent(uaConfigOverrides) { const uaOptions = this._createUaOptions(uaConfigOverrides); logger.info('sdk webrtc, creating UA', { uaOptions: { ...uaOptions, authorizationPassword: `${uaOptions?.authorizationPassword?.slice(0, 5)}xxxx` }, clientId: this.clientId, }); uaOptions.delegate = { onConnect: this.onConnect.bind(this), onDisconnect: this.onDisconnect.bind(this), onInvite: (invitation) => { logger.info('sdk webrtc on invite', { method: 'delegate.onInvite', clientId: this.clientId, id: invitation.id, remoteURI: invitation.remoteURI, }); this._setupSession(invitation); const shouldAutoAnswer = !!invitation.request.getHeader('alert-info'); this.eventEmitter.emit(INVITE, invitation, this.sessionWantsToDoVideo(invitation), shouldAutoAnswer); }, }; const ua = new UserAgent(uaOptions); ua.start(); if (ua.transport && ua.transport.connectPromise) { ua.transport.connectPromise.catch((e) => { logger.warn('Transport connect error', e); }); } ua.transport.onMessage = (rawMessage) => { const message = Parser.parseMessage(rawMessage, ua.transport.logger); // We have to re-sent the message to the UA ... // @ts-ignore: private ua.onTransportMessage(rawMessage); // And now do what we want with the message this.eventEmitter.emit(MESSAGE, message); if (message && message.method === C.MESSAGE) { // We have to manually reply to MESSAGE with a 200 OK or Asterisk will hangup. ua.userAgentCore.replyStateless(message, { statusCode: 200, }); } }; return ua; } isConnected() { return Boolean(this.userAgent?.isConnected()); } isConnecting() { return this.userAgent?.transport?.state === TransportState.Connecting; } isRegistered() { return Boolean(this.registerer && this.registerer.state === RegistererState.Registered); } onConnect() { logger.info('sdk webrtc connected', { method: 'delegate.onConnect', clientId: this.clientId }); this.eventEmitter.emit(CONNECTED); // @ts-ignore: private if (!this.isRegistered() && this.registerer?.waiting) { // @ts-ignore: private this.registerer.waitingToggle(false); } return this.register(); } async onDisconnect(error) { logger.info('sdk webrtc disconnected', { method: 'delegate.onConnect', clientId: this.clientId, error }); this.connectionPromise = null; // The UA will attempt to reconnect automatically when an error occurred this.eventEmitter.emit(DISCONNECTED, error); if (this.isRegistered()) { await this.unregister(); // @ts-ignore: private if (this.registerer?.waiting) { // @ts-ignore: private this.registerer.waitingToggle(false); } this.eventEmitter.emit(UNREGISTERED); } } async register(tries = 0) { const logInfo = { clientId: this.clientId, userAgent: !!this.userAgent, registered: this.isRegistered(), connectionPromise: !!this.connectionPromise, registerer: !!this.registerer, // @ts-ignore: private waiting: this.registerer && this.registerer.waiting, tries, skipRegister: this.skipRegister, }; this.forceClosed = false; if (this.skipRegister) { logger.info('sdk webrtc skip register...', logInfo); return Promise.resolve(); } logger.info('sdk webrtc registering...', logInfo); if (!this.userAgent) { logger.info('sdk webrtc recreating User Agent'); this.userAgent = this.createUserAgent(this.uaConfigOverrides); } if (!this.userAgent || this.isRegistered()) { logger.info('sdk webrtc registering aborted, already registered or no UA can be created'); return Promise.resolve(); } // @ts-ignore: private if (this.connectionPromise || this.registerer?.waiting) { logger.info('sdk webrtc registering aborted due to a registration in progress.', { clientId: this.clientId }); return Promise.resolve(); } const registerOptions = this._isWeb() ? {} : { extraContactHeaderParams: ['mobility=mobile'], }; const onRegisterFailed = () => { logger.info('sdk webrtc registering failed', { tries, clientId: this.clientId, registerer: !!this.registerer, forceClosed: this.forceClosed, }); if (this.forceClosed) { return; } this.connectionPromise = null; // @ts-ignore: private if (this.registerer && this.registerer.waiting) { // @ts-ignore: private this.registerer.waitingToggle(false); } if (tries <= MAX_REGISTER_TRIES) { logger.info('sdk webrtc registering, retrying...', { clientId: this.clientId, tries }); setTimeout(() => this.register(tries + 1), 300); } }; return this._connectIfNeeded().then(() => { // Avoid race condition with the close method called just before register and setting userAgent to null // during the resolution of the promise. if (!this.userAgent) { logger.info('sdk webrtc recreating User Agent after connection'); this.userAgent = this.createUserAgent(this.uaConfigOverrides); } logger.info('sdk webrtc registering, transport connected', { registerOptions, ua: !!this.userAgent, clientId: this.clientId }); this.registerer = new Registerer(this.userAgent, registerOptions); this.connectionPromise = null; this._monkeyPatchRegisterer(this.registerer); // Bind registerer events this.registerer.stateChange.addListener(newState => { logger.info('sdk webrtc registering, state changed', { newState, clientId: this.clientId }); if (newState === RegistererState.Registered && this.registerer && this.registerer.state === RegistererState.Registered) { this.eventEmitter.emit(REGISTERED); } else if (newState === RegistererState.Unregistered) { this.eventEmitter.emit(UNREGISTERED); } }); const options = { requestDelegate: { onReject: (response) => { logger.error('sdk webrtc registering, rejected', { clientId: this.clientId, response }); onRegisterFailed(); }, }, }; return this.registerer.register(options).catch(e => { logger.error('sdk webrtc registering, error', e); this.eventEmitter.emit(REGISTRATION_FAILED); return e; }); }).catch(error => { logger.error('sdk webrtc registering, transport error', error); onRegisterFailed(); }); } // Monkey patching sip.js to avoid issues during register.onReject _monkeyPatchRegisterer(registerer) { if (!registerer) { return; } // @ts-ignore: private const oldWaitingToggle = registerer.waitingToggle.bind(registerer); // @ts-ignore: private const oldUnregistered = registerer.unregistered.bind(registerer); // @ts-ignore: private registerer.waitingToggle = (waiting) => { // @ts-ignore: private if (!registerer || registerer.waiting === waiting) { return; } oldWaitingToggle(waiting); }; // @ts-ignore: private registerer.unregistered = () => { if (!registerer || registerer.state === RegistererState.Terminated) { return; } oldUnregistered(); }; } async unregister() { logger.info('sdk webrtc unregistering..', { clientId: this.clientId, userAgent: !!this.userAgent, registerer: !!this.registerer, }); try { return new Promise((resolve, reject) => { if (!this.registerer) { return resolve(); } const onRegisterStateChange = (state) => { if (state === RegistererState.Unregistered) { logger.info('sdk webrtc unregistered', { clientId: this.clientId }); if (this.registerer) { this.registerer.stateChange.addListener(onRegisterStateChange); } this._cleanupRegister(); resolve(); } }; this.registerer.stateChange.addListener(onRegisterStateChange); this.registerer.unregister().then().catch(e => { logger.error('sdk webrtc unregistering, promise error', e); this._cleanupRegister(); reject(); }); }); } catch (e) { logger.error('sdk webrtc unregistering, error', e); // Avoid issue with `undefined is not an object (evaluating 'new.target.prototype')` when triggering a new // error when the registerer is in a bad state this._cleanupRegister(); } } stop() { logger.info('sdk webrtc stop', { clientId: this.clientId, userAgent: !!this.userAgent }); if (!this.userAgent) { return Promise.resolve(); } return this.userAgent.stop().then(() => { return this._cleanupRegister(); }).catch(e => { logger.warn('sdk webrtc stop, error', { message: e.message, stack: e.stack, }); }); } call(number, enableVideo, audioOnly = false, conference = false, options = {}) { logger.info('sdk webrtc creating call', { clientId: this.clientId, number, enableVideo, audioOnly, conference, options, }); const inviterOptions = { sessionDescriptionHandlerOptionsReInvite: { conference, audioOnly, }, earlyMedia: true, ...options, }; if (audioOnly) { inviterOptions.sessionDescriptionHandlerModifiersReInvite = [stripVideo]; } const uri = this._makeURI(number); let session = null; if (uri) { session = this.userAgent ? new Inviter(this.userAgent, uri, inviterOptions) : null; } else { logger.error('Null URI'); } if (session) { this.storeSipSession(session); this._setupSession(session); } if (conference) { this.conferences[this.getSipSessionId(session)] = true; } const inviteOptions = { requestDelegate: { onAccept: (response) => { if (session?.sessionDescriptionHandler?.peerConnection) { (session?.sessionDescriptionHandler).peerConnection.sfu = conference; } this._onAccepted(session, response.session, true); }, onProgress: (payload) => { this._onProgress(payload.session, payload.message.statusCode === 183); }, onReject: (response) => { logger.info('on call rejected', { id: session?.id, // @ts-ignore: fromTag does not exist fromTag: session?.fromTag, }); this._stopSendingStats(session); this.stopNetworkMonitoring(session); this.eventEmitter.emit(REJECTED, session, response); }, }, sessionDescriptionHandlerOptions: this.getMediaConfiguration(enableVideo || false, conference), }; if (inviteOptions.sessionDescriptionHandlerOptions) { inviteOptions.sessionDescriptionHandlerOptions.audioOnly = audioOnly; } inviteOptions.sessionDescriptionHandlerModifiers = [replaceLocalIpModifier]; if (audioOnly) { inviteOptions.sessionDescriptionHandlerModifiers.push(stripVideo); } if (session) { // Do not await invite here or we'll miss the Establishing state transition // @ts-ignore: Property 'invitePromise' does not exist session.invitePromise = session.invite(inviteOptions).catch((e) => { logger.warn('sdk webrtc creating call, error', e); }); } return session; } answer(session, enableVideo) { logger.info('sdk webrtc answer call', { clientId: this.clientId, id: session.id, enableVideo, }); if (!session || !session.accept) { const error = 'No session to answer, or not an invitation'; logger.warn(error); return Promise.reject(new Error(error)); } const options = { sessionDescriptionHandlerOptions: this.getMediaConfiguration(enableVideo || false), }; return this._accept(session, options).then(() => { // @ts-ignore: private if (session.isCanceled) { const message = 'accepted a canceled session (or was canceled during the accept phase).'; logger.error(message, { id: session.id, }); this.onCallEnded(session); throw new CanceledCallError(message); } logger.info('sdk webrtc answer, accepted.'); this._onAccepted(session); }).catch(e => { logger.error(`answer call error for ${session ? session.id : 'n/a'}`, e); throw e; }); } async hangup(session) { const { state, id } = session; logger.info('sdk webrtc hangup call', { clientId: this.clientId, id, state }); try { this._stopSendingStats(session); this._cleanupMedia(session); delete this.sipSessions[this.getSipSessionId(session)]; // Check if Invitation or Inviter (Invitation = incoming call) const isInviter = session instanceof Inviter; // @see github.com/onsip/SIP.js/blob/f11dfd584bc9788ccfc94e03034020672b738975/src/platform/web/simple-user/simple-user.ts#L1004 const actions = { [SessionState.Initial]: isInviter ? this._cancel.bind(this, session) : this._reject.bind(this, session), [SessionState.Establishing]: isInviter ? this._cancel.bind(this, session) : this._reject.bind(this, session), [SessionState.Established]: this._bye.bind(this, session), }; // Handle different session status if (actions[state]) { return await actions[state](); } return await this._bye(session); } catch (error) { logger.warn('sdk webrtc hangup, error', error); } return Promise.resolve(null); } async getStats(session) { const pc = session.sessionDescriptionHandler?.peerConnection; if (!pc) { return null; } return pc.getStats(null); } // Fetch and emit an event at `interval` with session network stats startNetworkMonitoring(session, interval = 1000) { const sessionId = this.getSipSessionId(session); logger.info('starting network inspection', { id: sessionId, clientId: this.clientId, }); this.sessionNetworkStats[sessionId] = []; this.networkMonitoringInterval[sessionId] = setInterval(() => this._fetchNetworkStats(sessionId), interval); } stopNetworkMonitoring(session) { const sessionId = this.getSipSessionId(session); const exists = (sessionId in this.networkMonitoringInterval); logger.info('stopping network inspection', { clientId: this.clientId, id: sessionId, exists, }); if (exists) { clearInterval(this.networkMonitoringInterval[sessionId]); delete this.networkMonitoringInterval[sessionId]; delete this.sessionNetworkStats[sessionId]; } } async reject(session) { logger.info('sdk webrtc reject call', { clientId: this.clientId, id: session.id, }); try { if (session instanceof Invitation) { return this._reject(session); } if (session instanceof Inviter) { return this._cancel(session); } } catch (e) { logger.warn('Error when rejecting call', e.message, e.stack); } } async close(force = false) { logger.info('sdk webrtc closing client', { clientId: this.clientId, userAgent: !!this.userAgent, force, }); this.forceClosed = force; this._cleanupMedia(); this.connectionPromise = null; Object.values(this.audioElements).forEach((audioElement) => { // eslint-disable-next-line audioElement.srcObject = null; audioElement.pause(); }); this.audioElements = {}; if (!this.userAgent) { return; } this.stopHeartbeat(); if (this.userAgent) { this.userAgent.delegate = undefined; } // @ts-ignore: removeAllListeners does not exist this.userAgent.stateChange.removeAllListeners(); await this._disconnectTransport(force); this._cleanupRegister(); try { // Prevent `Connect aborted.` error when disconnecting this.userAgent.transport.connectReject = () => { }; // Don't wait here, It can take ~30s to stop ... this.userAgent.stop().catch(console.error); } catch (_) { // Avoid to raise exception when trying to close with hanged-up sessions remaining // eg: "INVITE not rejectable in state Completed" } this.userAgent = null; logger.info('sdk webrtc client closed', { clientId: this.clientId }); } getNumber(session) { if (!session) { return null; } // @ts-ignore: private return session.remoteIdentity.uri._normal.user; } mute(session) { logger.info('sdk webrtc mute', { id: session.id, }); this._toggleAudio(session, true); } unmute(session) { logger.info('sdk webrtc unmute', { id: session.id, }); this._toggleAudio(session, false); } isAudioMuted(session) { if (!session || !session.sessionDescriptionHandler) { return false; } let muted = true; const pc = session.sessionDescriptionHandler.peerConnection; if (!pc) { return false; } if (pc.getSenders) { if (!pc.getSenders().length) { return false; } pc.getSenders().forEach((sender) => { if (sender && sender.track && sender.track.kind === 'audio') { muted = muted && !sender.track.enabled; } }); } else { if (!pc.getLocalStreams().length) { return false; } pc.getLocalStreams().forEach((stream) => { stream.getAudioTracks().forEach(track => { muted = muted && !track.enabled; }); }); } return muted; } toggleCameraOn(session) { logger.info('sdk webrtc toggle camera on', { id: session.id, }); this._toggleVideo(session, false); } toggleCameraOff(session) { logger.info('sdk webrtc toggle camera off', { id: session.id, }); this._toggleVideo(session, true); } hold(session, isConference = false, hadVideo = false) { const sessionId = this.getSipSessionId(session); const hasVideo = hadVideo || this.hasLocalVideo(sessionId); logger.info('sdk webrtc hold', { sessionId, keys: Object.keys(this.heldSessions), // @ts-ignore: private pendingReinvite: !!session.pendingReinvite, isConference, hasVideo, }); if (sessionId in this.heldSessions) { return Promise.resolve(); } // @ts-ignore: private if (session.pendingReinvite) { return Promise.resolve(); } this.heldSessions[sessionId] = { hasVideo, isConference, }; // We should also mute the call, because when holding a call during a voicemail, the audio is still sent with the // `sendonly` direction this.mute(session); session.sessionDescriptionHandlerOptionsReInvite = { hold: true, conference: isConference, }; const options = this.getMediaConfiguration(false, isConference); if (!this._isWeb()) { options.sessionDescriptionHandlerModifiers = [holdModifier]; } options.sessionDescriptionHandlerOptions = { constraints: options.constraints, hold: true, conference: isConference, }; // Avoid sdh to create a new stream if (session.sessionDescriptionHandler) { // @ts-ignore: private session.sessionDescriptionHandler.localMediaStreamConstraints = options.constraints; } // Send re-INVITE return session.invite(options).catch((e) => { logger.warn('sdk webrtc re-invite during hold, error', e); }); } unhold(session, isConference = false) { const sessionId = this.getSipSessionId(session); const hasVideo = sessionId in this.heldSessions && this.heldSessions[sessionId].hasVideo; logger.info('sdk webrtc unhold', { sessionId, keys: Object.keys(this.heldSessions), // @ts-ignore: private pendingReinvite: !!session.pendingReinvite, isConference, hasVideo, }); // @ts-ignore: private if (session.pendingReinvite) { return Promise.resolve(); } this.unmute(session); delete this.heldSessions[this.getSipSessionId(session)]; session.sessionDescriptionHandlerOptionsReInvite = { hold: false, conference: isConference, }; const options = this.getMediaConfiguration(false, isConference); if (!this._isWeb()) { // We should sent an empty `sessionDescriptionHandlerModifiers` or sip.js will take the last sent modifiers // (eg: holdModifier) options.sessionDescriptionHandlerModifiers = []; } options.sessionDescriptionHandlerOptions = { constraints: options.constraints, hold: false, conference: isConference, }; // Send re-INVITE return session.invite(options).catch((e) => { logger.warn('sdk webrtc re-invite during resume, error', e); }); } // Returns true if a re-INVITE is required async upgradeToVideo(session, constraints, isConference) { const pc = session.sessionDescriptionHandler?.peerConnection; // Check if a video sender already exists let videoSender; if (isConference) { // We search for the last transceiver without `video-` in the mid (video- means remote transceiver) const transceivers = pc?.getTransceivers() || []; const transceiverIdx = lastIndexOf(transceivers, transceiver => transceiver.sender.track === null && transceiver.mid && transceiver.mid.indexOf('video') === -1); videoSender = transceiverIdx !== -1 ? transceivers[transceiverIdx].sender : null; } else { videoSender = pc && pc.getSenders && pc.getSenders().find((sender) => sender.track === null); } if (!videoSender) { // When no video sender found, it means that we're in the first video upgrade in 1:1 return; } // Reuse bidirectional video stream const newStream = await this.getStreamFromConstraints(constraints); if (!newStream) { console.warn(`Can't create media stream with: ${JSON.stringify(constraints || {})}`); return; } // Add previous local audio track if (constraints && !constraints.audio) { const localVideoStream = session.sessionDescriptionHandler?.localMediaStream; const localAudioTrack = localVideoStream.getTracks().find(track => track.kind === 'audio'); if (localAudioTrack) { newStream.addTrack(localAudioTrack); } } const videoTrack = newStream.getVideoTracks()[0]; if (videoTrack) { videoSender.replaceTrack(videoTrack); } this.setLocalMediaStream(this.getSipSessionId(session), newStream); return newStream; } downgradeToAudio(session) { // Release local video stream when downgrading to audio const sessionDescriptionHandler = session.sessionDescriptionHandler; const localStream = sessionDescriptionHandler?.localMediaStream; const pc = sessionDescriptionHandler?.peerConnection; const videoTracks = localStream.getVideoTracks(); // Remove video senders if (pc?.getSenders) { pc.getSenders().filter((sender) => sender.track && sender.track.kind === 'video').forEach((videoSender) => { videoSender.replaceTrack(null); }); } videoTracks.forEach((videoTrack) => { videoTrack.enabled = false; videoTrack.stop(); localStream.removeTrack(videoTrack); }); } async getStreamFromConstraints(constraints, conference = false) { const video = constraints && constraints.video; const { constraints: newConstraints, } = this.getMediaConfiguration(video, conference, constraints); let newStream = null; try { newStream = await workanoMediaStreamFactory(newConstraints); } catch (e) { // Nothing to do when the user cancel the screensharing prompt } if (!newStream) { return null; } newStream.local = true; return newStream; } getHeldSession(sessionId) { return this.heldSessions[sessionId]; } isCallHeld(session) { return this.getSipSessionId(session) in this.heldSessions; } isVideoRemotelyHeld(sessionId) { const pc = this.getPeerConnection(sessionId); const sdp = pc && pc.remoteDescription ? pc.remoteDescription.sdp : null; if (!sdp) { return false; } const videoDirection = getVideoDirection(sdp); return videoDirection === 'sendonly'; } sendDTMF(session, tone) { if (!session.sessionDescriptionHandler) { return false; } logger.info('Sending DTMF', { id: this.getSipSessionId(session), tone, }); return session.sessionDescriptionHandler.sendDtmf(tone); } message(destination, message) { const uri = this._makeURI(destination); if (!this.userAgent || !uri) { logger.warn('Null value on message', { uri, userAgent: this.userAgent }); return; } const messager = new Messager(this.userAgent, uri, message); messager.message(); } transfer(session, target) { this.hold(session); logger.info('Transfering a session', { id: this.getSipSessionId(session), target, }); const options = { requestDelegate: { onAccept: () => { this.hangup(session); }, }, }; setTimeout(() => { const uri = this._makeURI(target); if (!uri) { logger.warn('transfer timeout: null URI'); return; } session.refer(uri, options); }, 50); } // check https://sipjs.com/api/0.12.0/refer/referClientContext/ atxfer(session) { this.hold(session); logger.info('webrtc transfer started', { id: this.getSipSessionId(session), }); const result = { newSession: null, init: async (target) => { logger.info('webrtc transfer initialized', { id: this.getSipSessionId(session), target, }); result.newSession = await this.call(target); }, complete: () => { logger.info('webrtc transfer completed', { id: this.getSipSessionId(session), referId: this.getSipSessionId(result.newSession), }); session.refer(result.newSession); }, cancel: () => { this.hangup(result.newSession); this.unhold(session); }, }; return result; } // eslint-disable-next-line @typescript-eslint/default-param-last sendMessage(sipSession = null, body, contentType = 'text/plain') { if (!sipSession) { return; } logger.info('send WebRTC message', { sipId: sipSession.id, contentType, }); try { sipSession.message({ requestOptions: { body: { content: body, contentType, // @HEADSUP: contentDisposition is a required string, setting it to '' arbitrarily contentDisposition: '', }, }, }); } catch (e) { console.warn(e); } } pingServer() { if (!this.isConnected()) { return; } const core = this.userAgent?.userAgentCore; const fromURI = this._makeURI(this.config.authorizationUser || ''); const toURI = new URI('sip', '', this.config.host); if (fromURI) { const message = core?.makeOutgoingRequestMessage('OPTIONS', toURI, fromURI, toURI, {}); if (message) { return core?.request(message); } logger.warn('pingServer: null message'); } logger.warn('pingServer: null fromURI'); } getState() { // @ts-ignore: something fishy here: `states` content doesn't match UserAgentState at all return this.userAgent ? states[this.userAgent.state] : UserAgentState.Stopped; } getContactIdentifier() { return this.userAgent ? `${this.userAgent.configuration.contactName}/${this.userAgent.contact.uri}` : null; } isFirefox() { return this._isWeb() && navigator.userAgent.toLowerCase().indexOf('firefox') > -1; } changeAudioOutputVolume(volume) { logger.info('Changing audio output volume', { volume, }); Object.values(this.audioElements).forEach(audioElement => { if (audioElement instanceof HTMLAudioElement) { // eslint-disable-next-line no-param-reassign audioElement.volume = volume; } }); this.audioOutputVolume = volume; } async changeAudioOutputDevice(id) { logger.info('Changing audio output device', { id, }); this.audioOutputDeviceId = id; const promises = []; Object.values(this.audioElements).forEach(audioElement => promises.push(audioElement.setSinkId(id))); await Promise.allSettled(promises); } async changeAudioInputDevice(id, session, force) { const currentId = this.getAudioDeviceId(); logger.info('setting audio input device', { id, currentId, session: !!session, }); if (!force && id === currentId) { return null; } // in order to handle an audio track change for default devices // we need the actual id of the device (not 'default') let deviceId = id; if (id === 'default' && navigator.mediaDevices) { const devices = await navigator.mediaDevices.enumerateDevices(); const defaultDevice = devices.find(device => device.deviceId === 'default'); if (defaultDevice) { const deviceLabel = defaultDevice.label.replace('Default - ', ''); const targetDevice = devices.find(device => device.label === deviceLabel); if (targetDevice) { deviceId = targetDevice.deviceId; if (!force && deviceId === currentId) { return null; } } } } // let's update the local audio value if (this.audio) { this.audio = { deviceId: { exact: deviceId, }, }; } if (session && navigator.mediaDevices) { const sdh = session.sessionDescriptionHandler; const pc = sdh?.peerConnection; const constraints = { audio: { deviceId: { exact: deviceId, }, }, }; const stream = await navigator.mediaDevices.getUserMedia(constraints); const audioTrack = stream.getAudioTracks()[0]; const sender = pc && pc.getSenders && pc.getSenders().find((s) => audioTrack && s && s.track && s.track.kind === audioTrack.kind); if (sender) { if (sender.track) { audioTrack.enabled = sender.track.enabled; } sender.replaceTrack(audioTrack); } return stream; } return null; } async changeVideoInputDevice(id, session) { this.setVideoInputDevice(id); if (session) { return this.changeSessionVideoInputDevice(id, session); } } setVideoInputDevice(id) { const currentId = this.getVideoDeviceId(); logger.info('setting video input device', { id, currentId, }); if (id === currentId) { return null; } // let's make sure we don't lose other video constraints settings -- width, height, frameRate... const videoObject = typeof this.video === 'object' ? this.video : {}; this.video = { ...videoObject, deviceId: { exact: id, }, }; } changeSessionVideoInputDevice(id, session) { if (!this.sessionWantsToDoVideo(session)) { return Promise.resolve(); } const sdh = session.sessionDescriptionHandler; const pc = sdh.peerConnection; const sessionId = this.getSipSessionId(session); const localStream = this.getLocalStream(sessionId); logger.info('changing video input device', { id, sessionId, }); // Release old video stream if (localStream) { localStream.getVideoTracks() .filter((track) => track.enabled) .forEach((track) => track.stop()); } const constraints = { video: id ? { deviceId: { exact: id, }, } : true, }; return navigator.mediaDevices.getUserMedia(constraints).then(async (stream) => { const videoTrack = stream.getVideoTracks()[0]; let sender = pc && pc.getSenders && pc.getSenders().find((s) => videoTrack && s && s.track && s.track.kind === videoTrack.kind); let wasTrackEnabled = false; if (!sender) { sender = pc && pc.getSenders && pc.getSenders().find((s) => !s.track); } if (sender) { // No video track means video not enabled wasTrackEnabled = sender.track ? sender.track.enabled : false; videoTrack.enabled = wasTrackEnabled; if (!wasTrackEnabled) { videoTrack.stop(); } sender.replaceTrack(wasTrackEnabled ? videoTrack : null); } // let's update the local stream this.eventEmitter.emit('onVideoInputChange', stream); this.setLocalMediaStream(sessionId, stream); return stream; }); } getAudioDeviceId() { return this.audio && typeof this.audio === 'object' && 'deviceId' in this.audio ? this.audio.deviceId?.exact : undefined; } getVideoDeviceId() { return this.video && typeof this.video === 'object' && 'deviceId' in this.video ? this.video.deviceId?.exact : undefined; } reinvite(sipSession, newConstraints = null, conference = false, audioOnly = false, iceRestart = false) { if (!sipSession) { return Promise.resolve(); } // @ts-ignore: private if (sipSession.pendingReinvite) { return Promise.resolve(); } const wasMuted = this.isAudioMuted(sipSession); const shouldDoVideo = newConstraints ? newConstraints.video : this.sessionWantsToDoVideo(sipSession); const shouldDoScreenSharing = newConstraints && newConstraints.screen; const desktop = newConstraints && newConstraints.desktop; logger.info('Sending reinvite', { clientId: this.clientId, id: this.getSipSessionId(sipSession), newConstraints, conference, audioOnly, wasMuted, shouldDoVideo, shouldDoScreenSharing, desktop, }); // When upgrading to video, remove the `stripVideo` modifiers if (newConstraints && newConstraints.video) { const modifiers = sipSession.sessionDescriptionHandlerModifiersReInvite; sipSession.sessionDescriptionHandlerModifiersReInvite = modifiers.filter(modifier => modifier !== stripVideo); } sipSession.sessionDescriptionHandlerOptionsReInvite = { ...sipSession.sessionDescriptionHandlerOptionsReInvite, conference, audioOnly, }; const { constraints, } = this.getMediaConfiguration(shouldDoVideo, conference, newConstraints); return sipSession.invite({ requestDelegate: { onAccept: (response) => { // Update the SDP body to be able to call sessionWantsToDoVideo correctly in `_setup[Local|Remote]Media`. // Can't set directly sipSession.body because it's a getter. // @ts-ignore: private if (sipSession instanceof Inviter && sipSession.outgoingRequestMessage.body) { // @ts-ignore: private sipSession.outgoingRequestMessage.body.body = response.message.body; } else if (sipSession instanceof Invitation) { // @ts-ignore: private sipSession.incomingInviteRequest.message.body = response.message.body; } logger.info('on re-INVITE accepted', { id: this.getSipSessionId(sipSession), wasMuted, shouldDoScreenSharing, }); this.updateRemoteStream(this.getSipSessionId(sipSession), false); if (wasMuted) { this.mute(sipSession); } this._onAccepted(sipSession, response.session, false, false); if (shouldDoScreenSharing) { this.eventEmitter.emit(ON_SCREEN_SHARING_REINVITE, sipSession, response, desktop); } return this.eventEmitter.emit(ON_REINVITE, sipSession, response); }, }, sessionDescriptionHandlerModifiers: [replaceLocalIpModifier], requestOptions: { extraHeaders: [`Subject: ${shouldDoScreenSharing ? 'screenshare' : 'upgrade-video'}`], }, sessionDescriptionHandlerOptions: { constraints, conference, audioOnly, offerOptions: { iceRestart, }, }, }); } async getUserMedia(constraints) { const newConstraints = { audio: this._getAudioConstraints(), video: this._getVideoConstraints(constraints.video), }; return navigator.mediaDevices.getUserMedia(newConstraints); } getPeerConnection(sessionId) { const sipSession = this.sipSessions[sessionId]; if (!sipSession) { return null; } return sipSession.sessionDescriptionHandler ? sipSession.sessionDescriptionHandler.peerConnection : null; } // Local streams getLocalStream(sessionId) { const sipSession = this.sipSessions[sessionId]; return sipSession?.sessionDescriptionHandler?.localMediaStream || null; } getLocalTracks(sessionId) { const localStream = this.getLocalStream(sessionId); if (!localStream) { return []; } retur