kod-temp
Version:
Workano Communications SDK
1,257 lines • 89.4 kB
JavaScript
/* 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