UNPKG

@twilio/voice-sdk

Version:
1,199 lines 108 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { EventEmitter } from 'events'; import { levels as LogLevels } from 'loglevel'; import AudioHelper from './audiohelper'; import { AudioProcessorEventObserver } from './audioprocessoreventobserver'; import Call from './call'; import * as C from './constants'; import DialtonePlayer from './dialtonePlayer'; import { AuthorizationErrors, ClientErrors, GeneralErrors, getPreciseSignalingErrorByCode, InvalidArgumentError, InvalidStateError, NotSupportedError, } from './errors'; import Publisher from './eventpublisher'; import Log from './log'; import { PreflightTest } from './preflight/preflight'; import PStream from './pstream'; import { createEventGatewayURI, createSignalingEndpointURL, getChunderURIs, getRegionShortcode, regionToEdge, } from './regions'; import * as rtc from './rtc'; import getUserMedia from './rtc/getusermedia'; import { generateVoiceEventSid } from './sid'; import Sound from './sound'; import { isLegacyEdge, isUnifiedPlanDefault, promisifyEvents, queryToJson, } from './util'; const REGISTRATION_INTERVAL = 30000; const RINGTONE_PLAY_TIMEOUT = 2000; const PUBLISHER_PRODUCT_NAME = 'twilio-js-sdk'; const INVALID_TOKEN_MESSAGE = 'Parameter "token" must be of type "string".'; /** * Twilio Device. Allows registration for incoming calls, and placing outgoing calls. */ class Device extends EventEmitter { /** * The AudioContext to be used by {@link Device} instances. * @private */ static get audioContext() { return Device._audioContext; } /** * Which sound file extension is supported. * @private */ static get extension() { // NOTE(mroberts): Node workaround. const a = typeof document !== 'undefined' ? document.createElement('audio') : { canPlayType: false }; let canPlayMp3; try { canPlayMp3 = a.canPlayType && !!a.canPlayType('audio/mpeg').replace(/no/, ''); } catch (e) { canPlayMp3 = false; } let canPlayVorbis; try { canPlayVorbis = a.canPlayType && !!a.canPlayType('audio/ogg;codecs=\'vorbis\'').replace(/no/, ''); } catch (e) { canPlayVorbis = false; } return (canPlayVorbis && !canPlayMp3) ? 'ogg' : 'mp3'; } /** * Whether or not this SDK is supported by the current browser. */ static get isSupported() { return rtc.enabled(); } /** * Package name of the SDK. */ static get packageName() { return C.PACKAGE_NAME; } /** * Run some tests to identify issues, if any, prohibiting successful calling. * @param token - A Twilio JWT token string * @param options */ static runPreflight(token, options) { return new PreflightTest(token, Object.assign({ audioContext: Device._getOrCreateAudioContext() }, options)); } /** * String representation of {@link Device} class. * @private */ static toString() { return '[Twilio.Device class]'; } /** * Current SDK version. */ static get version() { return C.RELEASE_VERSION; } /** * Initializes the AudioContext instance shared across the Voice SDK, * or returns the existing instance if one has already been initialized. */ static _getOrCreateAudioContext() { if (!Device._audioContext) { if (typeof AudioContext !== 'undefined') { Device._audioContext = new AudioContext(); } else if (typeof webkitAudioContext !== 'undefined') { Device._audioContext = new webkitAudioContext(); } } return Device._audioContext; } /** * Construct a {@link Device} instance. The {@link Device} can be registered * to make and listen for calls using {@link Device.register}. * @param options */ constructor(token, options = {}) { super(); /** * The currently active {@link Call}, if there is one. */ this._activeCall = null; /** * The AudioHelper instance associated with this {@link Device}. */ this._audio = null; /** * The AudioProcessorEventObserver instance to use */ this._audioProcessorEventObserver = null; /** * An audio input MediaStream to pass to new {@link Call} instances. */ this._callInputStream = null; /** * An array of {@link Call}s. Though only one can be active, multiple may exist when there * are multiple incoming, unanswered {@link Call}s. */ this._calls = []; /** * An array of {@link Device} IDs to be used to play sounds through, to be passed to * new {@link Call} instances. */ this._callSinkIds = ['default']; /** * The list of chunder URIs that will be passed to PStream */ this._chunderURIs = []; /** * Default options used by {@link Device}. */ this._defaultOptions = { allowIncomingWhileBusy: false, closeProtection: false, codecPreferences: [Call.Codec.PCMU, Call.Codec.Opus], dscp: true, enableImprovedSignalingErrorPrecision: false, forceAggressiveIceNomination: false, logLevel: LogLevels.ERROR, maxCallSignalingTimeoutMs: 0, preflight: false, sounds: {}, tokenRefreshMs: 10000, voiceEventSidGenerator: generateVoiceEventSid, }; /** * The name of the edge the {@link Device} is connected to. */ this._edge = null; /** * The name of the home region the {@link Device} is connected to. */ this._home = null; /** * The identity associated with this Device. */ this._identity = null; /** * An instance of Logger to use. */ this._log = new Log('Device'); /** * The internal promise created when calling {@link Device.makeCall}. */ this._makeCallPromise = null; /** * The options passed to {@link Device} constructor or {@link Device.updateOptions}. */ this._options = {}; /** * The preferred URI to (re)-connect signaling to. */ this._preferredURI = null; /** * An Insights Event Publisher. */ this._publisher = null; /** * The region the {@link Device} is connected to. */ this._region = null; /** * A timeout ID for a setTimeout schedule to re-register the {@link Device}. */ this._regTimer = null; /** * Boolean representing whether or not the {@link Device} was registered when * receiving a signaling `offline`. Determines if the {@link Device} attempts * a `re-register` once signaling is re-established when receiving a * `connected` event from the stream. */ this._shouldReRegister = false; /** * A Map of Sounds to play. */ this._soundcache = new Map(); /** * The current status of the {@link Device}. */ this._state = Device.State.Unregistered; /** * A map from {@link Device.State} to {@link Device.EventName}. */ this._stateEventMapping = { [Device.State.Destroyed]: Device.EventName.Destroyed, [Device.State.Unregistered]: Device.EventName.Unregistered, [Device.State.Registering]: Device.EventName.Registering, [Device.State.Registered]: Device.EventName.Registered, }; /** * The Signaling stream. */ this._stream = null; /** * A promise that will resolve when the Signaling stream is ready. */ this._streamConnectedPromise = null; /** * A timeout to track when the current AccessToken will expire. */ this._tokenWillExpireTimeout = null; /** * Create the default Insights payload * @param call */ this._createDefaultPayload = (call) => { const payload = { aggressive_nomination: this._options.forceAggressiveIceNomination, browser_extension: this._isBrowserExtension, dscp: !!this._options.dscp, ice_restart_enabled: true, platform: rtc.getMediaEngine(), sdk_version: C.RELEASE_VERSION, }; function setIfDefined(propertyName, value) { if (value) { payload[propertyName] = value; } } if (call) { const callSid = call.parameters.CallSid; setIfDefined('call_sid', /^TJ/.test(callSid) ? undefined : callSid); setIfDefined('temp_call_sid', call.outboundConnectionId); setIfDefined('audio_codec', call.codec); payload.direction = call.direction; } setIfDefined('gateway', this._stream && this._stream.gateway); setIfDefined('region', this._stream && this._stream.region); return payload; }; /** * Called when a 'close' event is received from the signaling stream. */ this._onSignalingClose = () => { this._stream = null; this._streamConnectedPromise = null; }; /** * Called when a 'connected' event is received from the signaling stream. */ this._onSignalingConnected = (payload) => { var _a; const region = getRegionShortcode(payload.region); this._edge = payload.edge || regionToEdge[region] || payload.region; this._region = region || payload.region; this._home = payload.home; (_a = this._publisher) === null || _a === void 0 ? void 0 : _a.setHost(createEventGatewayURI(payload.home)); if (payload.token) { this._identity = payload.token.identity; if (typeof payload.token.ttl === 'number' && typeof this._options.tokenRefreshMs === 'number') { const ttlMs = payload.token.ttl * 1000; const timeoutMs = Math.max(0, ttlMs - this._options.tokenRefreshMs); this._tokenWillExpireTimeout = setTimeout(() => { this._log.debug('#tokenWillExpire'); this.emit('tokenWillExpire', this); if (this._tokenWillExpireTimeout) { clearTimeout(this._tokenWillExpireTimeout); this._tokenWillExpireTimeout = null; } }, timeoutMs); } } const preferredURIs = this._getChunderws() || getChunderURIs(this._edge); if (preferredURIs.length > 0) { const [preferredURI] = preferredURIs; this._preferredURI = createSignalingEndpointURL(preferredURI); } else { this._log.warn('Could not parse a preferred URI from the stream#connected event.'); } // The signaling stream emits a `connected` event after reconnection, if the // device was registered before this, then register again. if (this._shouldReRegister) { this.register(); } }; /** * Called when an 'error' event is received from the signaling stream. */ this._onSignalingError = (payload) => { if (typeof payload !== 'object') { this._log.warn('Invalid signaling error payload', payload); return; } const { error: originalError, callsid, voiceeventsid } = payload; // voiceeventsid is for call message events which are handled in the call object // missing originalError shouldn't be possible but check here to fail properly if (typeof originalError !== 'object' || !!voiceeventsid) { this._log.warn('Ignoring signaling error payload', { originalError, voiceeventsid }); return; } const call = (typeof callsid === 'string' && this._findCall(callsid)) || undefined; const { code, message: customMessage } = originalError; let { twilioError } = originalError; if (typeof code === 'number') { if (code === 31201) { twilioError = new AuthorizationErrors.AuthenticationFailed(originalError); } else if (code === 31204) { twilioError = new AuthorizationErrors.AccessTokenInvalid(originalError); } else if (code === 31205) { // Stop trying to register presence after token expires this._stopRegistrationTimer(); twilioError = new AuthorizationErrors.AccessTokenExpired(originalError); } else { const errorConstructor = getPreciseSignalingErrorByCode(!!this._options.enableImprovedSignalingErrorPrecision, code); if (typeof errorConstructor !== 'undefined') { twilioError = new errorConstructor(originalError); } } } if (!twilioError) { this._log.error('Unknown signaling error: ', originalError); twilioError = new GeneralErrors.UnknownError(customMessage, originalError); } this._log.error('Received error: ', twilioError); this._log.debug('#error', originalError); this.emit(Device.EventName.Error, twilioError, call); }; /** * Called when an 'invite' event is received from the signaling stream. */ this._onSignalingInvite = (payload) => __awaiter(this, void 0, void 0, function* () { var _a; const wasBusy = !!this._activeCall; if (wasBusy && !this._options.allowIncomingWhileBusy) { this._log.info('Device busy; ignoring incoming invite'); return; } if (!payload.callsid || !payload.sdp) { this._log.debug('#error', payload); this.emit(Device.EventName.Error, new ClientErrors.BadRequest('Malformed invite from gateway')); return; } const callParameters = payload.parameters || {}; callParameters.CallSid = callParameters.CallSid || payload.callsid; const customParameters = Object.assign({}, queryToJson(callParameters.Params)); this._makeCallPromise = this._makeCall(customParameters, { callParameters, enableImprovedSignalingErrorPrecision: !!this._options.enableImprovedSignalingErrorPrecision, offerSdp: payload.sdp, reconnectToken: payload.reconnect, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }); let call; try { call = yield this._makeCallPromise; } finally { this._makeCallPromise = null; } this._calls.push(call); call.once('accept', () => { this._soundcache.get(Device.SoundName.Incoming).stop(); this._publishNetworkChange(); }); const play = (((_a = this._audio) === null || _a === void 0 ? void 0 : _a.incoming()) && !wasBusy) ? () => this._soundcache.get(Device.SoundName.Incoming).play() : () => Promise.resolve(); this._showIncomingCall(call, play); }); /** * Called when an 'offline' event is received from the signaling stream. */ this._onSignalingOffline = () => { this._log.info('Stream is offline'); this._edge = null; this._region = null; this._shouldReRegister = this.state !== Device.State.Unregistered; this._setState(Device.State.Unregistered); }; /** * Called when a 'ready' event is received from the signaling stream. */ this._onSignalingReady = () => { this._log.info('Stream is ready'); this._setState(Device.State.Registered); }; /** * Publish a NetworkInformation#change event to Insights if there's an active {@link Call}. */ this._publishNetworkChange = () => { if (!this._activeCall) { return; } if (this._networkInformation) { this._publisher.info('network-information', 'network-change', { connection_type: this._networkInformation.type, downlink: this._networkInformation.downlink, downlinkMax: this._networkInformation.downlinkMax, effective_type: this._networkInformation.effectiveType, rtt: this._networkInformation.rtt, }, this._activeCall); } }; /** * Update the input stream being used for calls so that any current call and all future calls * will use the new input stream. * @param inputStream */ this._updateInputStream = (inputStream) => { const call = this._activeCall; if (call && !inputStream) { return Promise.reject(new InvalidStateError('Cannot unset input device while a call is in progress.')); } this._callInputStream = inputStream; return call ? call._setInputTracksFromStream(inputStream) : Promise.resolve(); }; /** * Update the device IDs of output devices being used to play sounds through. * @param type - Whether to update ringtone or speaker sounds * @param sinkIds - An array of device IDs */ this._updateSinkIds = (type, sinkIds) => { const promise = type === 'ringtone' ? this._updateRingtoneSinkIds(sinkIds) : this._updateSpeakerSinkIds(sinkIds); return promise.then(() => { this._publisher.info('audio', `${type}-devices-set`, { audio_device_ids: sinkIds, }, this._activeCall); }, error => { this._publisher.error('audio', `${type}-devices-set-failed`, { audio_device_ids: sinkIds, message: error.message, }, this._activeCall); throw error; }); }; // Setup loglevel asap to avoid missed logs this._setupLoglevel(options.logLevel); this._logOptions('constructor', options); this.updateToken(token); if (isLegacyEdge()) { throw new NotSupportedError('Microsoft Edge Legacy (https://support.microsoft.com/en-us/help/4533505/what-is-microsoft-edge-legacy) ' + 'is deprecated and will not be able to connect to Twilio to make or receive calls after September 1st, 2020. ' + 'Please see this documentation for a list of supported browsers ' + 'https://www.twilio.com/docs/voice/client/javascript#supported-browsers'); } if (!Device.isSupported && options.ignoreBrowserSupport) { if (window && window.location && window.location.protocol === 'http:') { throw new NotSupportedError(`twilio.js wasn't able to find WebRTC browser support. \ This is most likely because this page is served over http rather than https, \ which does not support WebRTC in many browsers. Please load this page over https and \ try again.`); } throw new NotSupportedError(`twilio.js 1.3+ SDKs require WebRTC browser support. \ For more information, see <https://www.twilio.com/docs/api/client/twilio-js>. \ If you have any questions about this announcement, please contact \ Twilio Support at <help@twilio.com>.`); } const root = globalThis; const browser = root.msBrowser || root.browser || root.chrome; this._isBrowserExtension = (!!browser && !!browser.runtime && !!browser.runtime.id) || (!!root.safari && !!root.safari.extension); if (this._isBrowserExtension) { this._log.info('Running as browser extension.'); } if (navigator) { const n = navigator; this._networkInformation = n.connection || n.mozConnection || n.webkitConnection; } if (this._networkInformation && typeof this._networkInformation.addEventListener === 'function') { this._networkInformation.addEventListener('change', this._publishNetworkChange); } Device._getOrCreateAudioContext(); if (Device._audioContext) { if (!Device._dialtonePlayer) { Device._dialtonePlayer = new DialtonePlayer(Device._audioContext); } } if (typeof Device._isUnifiedPlanDefault === 'undefined') { Device._isUnifiedPlanDefault = typeof window !== 'undefined' && typeof RTCPeerConnection !== 'undefined' && typeof RTCRtpTransceiver !== 'undefined' ? isUnifiedPlanDefault(window, window.navigator, RTCPeerConnection, RTCRtpTransceiver) : false; } this._boundDestroy = this.destroy.bind(this); this._boundConfirmClose = this._confirmClose.bind(this); if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('unload', this._boundDestroy); window.addEventListener('pagehide', this._boundDestroy); } this.updateOptions(options); } /** * Return the {@link AudioHelper} used by this {@link Device}. */ get audio() { return this._audio; } /** * Make an outgoing Call. * @param options */ connect() { return __awaiter(this, arguments, void 0, function* (options = {}) { this._log.debug('.connect', JSON.stringify(options)); this._throwIfDestroyed(); if (this._activeCall) { throw new InvalidStateError('A Call is already active'); } let customParameters; let parameters; let signalingReconnectToken; if (options.connectToken) { try { const connectTokenParts = JSON.parse(decodeURIComponent(atob(options.connectToken))); customParameters = connectTokenParts.customParameters; parameters = connectTokenParts.parameters; signalingReconnectToken = connectTokenParts.signalingReconnectToken; } catch (_a) { throw new InvalidArgumentError('Cannot parse connectToken'); } if (!parameters || !parameters.CallSid || !signalingReconnectToken) { throw new InvalidArgumentError('Invalid connectToken'); } } let isReconnect = false; let twimlParams = {}; const callOptions = { enableImprovedSignalingErrorPrecision: !!this._options.enableImprovedSignalingErrorPrecision, rtcConfiguration: options.rtcConfiguration, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }; if (signalingReconnectToken && parameters) { isReconnect = true; callOptions.callParameters = parameters; callOptions.reconnectCallSid = parameters.CallSid; callOptions.reconnectToken = signalingReconnectToken; twimlParams = customParameters || twimlParams; } else { twimlParams = options.params || twimlParams; } let activeCall; this._makeCallPromise = this._makeCall(twimlParams, callOptions, isReconnect); try { activeCall = this._activeCall = yield this._makeCallPromise; } finally { this._makeCallPromise = null; } // Make sure any incoming calls are ignored this._calls.splice(0).forEach(call => call.ignore()); // Stop the incoming sound if it's playing this._soundcache.get(Device.SoundName.Incoming).stop(); activeCall.accept({ rtcConstraints: options.rtcConstraints }); this._publishNetworkChange(); return activeCall; }); } /** * Return the calls that this {@link Device} is maintaining. */ get calls() { return this._calls; } /** * Destroy the {@link Device}, freeing references to be garbage collected. */ destroy() { var _a; this._log.debug('.destroy'); this._log.debug('Rejecting any incoming calls'); const calls = this._calls.slice(0); calls.forEach((call) => call.reject()); this.disconnectAll(); this._stopRegistrationTimer(); this._destroyStream(); this._destroyAudioHelper(); (_a = this._audioProcessorEventObserver) === null || _a === void 0 ? void 0 : _a.destroy(); this._destroyPublisher(); if (this._networkInformation && typeof this._networkInformation.removeEventListener === 'function') { this._networkInformation.removeEventListener('change', this._publishNetworkChange); } if (typeof window !== 'undefined' && window.removeEventListener) { window.removeEventListener('beforeunload', this._boundConfirmClose); window.removeEventListener('unload', this._boundDestroy); window.removeEventListener('pagehide', this._boundDestroy); } this._setState(Device.State.Destroyed); EventEmitter.prototype.removeAllListeners.call(this); } /** * Disconnect all {@link Call}s. */ disconnectAll() { this._log.debug('.disconnectAll'); const calls = this._calls.splice(0); calls.forEach((call) => call.disconnect()); if (this._activeCall) { this._activeCall.disconnect(); } } /** * Returns the {@link Edge} value the {@link Device} is currently connected * to. The value will be `null` when the {@link Device} is offline. */ get edge() { return this._edge; } /** * Returns the home value the {@link Device} is currently connected * to. The value will be `null` when the {@link Device} is offline. */ get home() { return this._home; } /** * Returns the identity associated with the {@link Device} for incoming calls. Only * populated when registered. */ get identity() { return this._identity; } /** * Whether the Device is currently on an active Call. */ get isBusy() { return !!this._activeCall; } /** * Register the `Device` to the Twilio backend, allowing it to receive calls. */ register() { return __awaiter(this, void 0, void 0, function* () { this._log.debug('.register'); if (this.state !== Device.State.Unregistered) { throw new InvalidStateError(`Attempt to register when device is in state "${this.state}". ` + `Must be "${Device.State.Unregistered}".`); } this._shouldReRegister = false; this._setState(Device.State.Registering); yield (this._streamConnectedPromise || this._setupStream()); yield this._sendPresence(true); yield promisifyEvents(this, Device.State.Registered, Device.State.Unregistered); }); } /** * Get the state of this {@link Device} instance */ get state() { return this._state; } /** * Get the token used by this {@link Device}. */ get token() { return this._token; } /** * String representation of {@link Device} instance. * @private */ toString() { return '[Twilio.Device instance]'; } /** * Unregister the `Device` to the Twilio backend, disallowing it to receive * calls. */ unregister() { return __awaiter(this, void 0, void 0, function* () { this._log.debug('.unregister'); if (this.state !== Device.State.Registered) { throw new InvalidStateError(`Attempt to unregister when device is in state "${this.state}". ` + `Must be "${Device.State.Registered}".`); } this._shouldReRegister = false; const stream = yield this._streamConnectedPromise; const streamOfflinePromise = new Promise(resolve => { stream.on('offline', resolve); }); yield this._sendPresence(false); yield streamOfflinePromise; }); } /** * Set the options used within the {@link Device}. * @param options */ updateOptions(options = {}) { this._logOptions('updateOptions', options); if (this.state === Device.State.Destroyed) { throw new InvalidStateError(`Attempt to "updateOptions" when device is in state "${this.state}".`); } this._options = Object.assign(Object.assign(Object.assign({}, this._defaultOptions), this._options), options); const originalChunderURIs = new Set(this._chunderURIs); const newChunderURIs = this._chunderURIs = (this._getChunderws() || getChunderURIs(this._options.edge)).map(createSignalingEndpointURL); let hasChunderURIsChanged = originalChunderURIs.size !== newChunderURIs.length; if (!hasChunderURIsChanged) { for (const uri of newChunderURIs) { if (!originalChunderURIs.has(uri)) { hasChunderURIsChanged = true; break; } } } if (this.isBusy && hasChunderURIsChanged) { throw new InvalidStateError('Cannot change Edge while on an active Call'); } this._setupLoglevel(this._options.logLevel); for (const name of Object.keys(Device._defaultSounds)) { const soundDef = Device._defaultSounds[name]; const defaultUrl = `${C.SOUNDS_BASE_URL}/${soundDef.filename}.${Device.extension}` + `?cache=${C.RELEASE_VERSION}`; const soundUrl = this._options.sounds && this._options.sounds[name] || defaultUrl; const sound = new (this._options.Sound || Sound)(name, soundUrl, { audioContext: this._options.disableAudioContextSounds ? null : Device.audioContext, maxDuration: soundDef.maxDuration, shouldLoop: soundDef.shouldLoop, }); this._soundcache.set(name, sound); } this._setupAudioHelper(); this._setupPublisher(); if (hasChunderURIsChanged && this._streamConnectedPromise) { this._setupStream(); } // Setup close protection and make sure we clean up ongoing calls on unload. if (typeof window !== 'undefined' && typeof window.addEventListener === 'function' && this._options.closeProtection) { window.removeEventListener('beforeunload', this._boundConfirmClose); window.addEventListener('beforeunload', this._boundConfirmClose); } } /** * Update the token used by this {@link Device} to connect to Twilio. * It is recommended to call this API after [[Device.tokenWillExpireEvent]] is emitted, * and before or after a call to prevent a potential ~1s audio loss during the update process. * @param token */ updateToken(token) { this._log.debug('.updateToken'); if (this.state === Device.State.Destroyed) { throw new InvalidStateError(`Attempt to "updateToken" when device is in state "${this.state}".`); } if (typeof token !== 'string') { throw new InvalidArgumentError(INVALID_TOKEN_MESSAGE); } this._token = token; if (this._stream) { this._stream.setToken(this._token); } if (this._publisher) { this._publisher.setToken(this._token); } } /** * Called on window's beforeunload event if closeProtection is enabled, * preventing users from accidentally navigating away from an active call. * @param event */ _confirmClose(event) { if (!this._activeCall) { return ''; } const closeProtection = this._options.closeProtection || false; const confirmationMsg = typeof closeProtection !== 'string' ? 'A call is currently in-progress. Leaving or reloading this page will end the call.' : closeProtection; (event || window.event).returnValue = confirmationMsg; return confirmationMsg; } /** * Destroy the AudioHelper. */ _destroyAudioHelper() { if (!this._audio) { return; } this._audio._destroy(); this._audio = null; } /** * Destroy the publisher. */ _destroyPublisher() { // Attempt to destroy non-existent publisher. if (!this._publisher) { return; } this._publisher = null; } /** * Destroy the connection to the signaling server. */ _destroyStream() { if (this._stream) { this._stream.removeListener('close', this._onSignalingClose); this._stream.removeListener('connected', this._onSignalingConnected); this._stream.removeListener('error', this._onSignalingError); this._stream.removeListener('invite', this._onSignalingInvite); this._stream.removeListener('offline', this._onSignalingOffline); this._stream.removeListener('ready', this._onSignalingReady); this._stream.destroy(); this._stream = null; } this._onSignalingOffline(); this._streamConnectedPromise = null; } /** * Find a {@link Call} by its CallSid. * @param callSid */ _findCall(callSid) { return this._calls.find(call => call.parameters.CallSid === callSid || call.outboundConnectionId === callSid) || null; } /** * Get chunderws array from the chunderw param */ _getChunderws() { return typeof this._options.chunderw === 'string' ? [this._options.chunderw] : Array.isArray(this._options.chunderw) ? this._options.chunderw : null; } /** * Utility function to log device options */ _logOptions(caller, options = {}) { // Selectively log options that users can modify. // Also, convert user overrides. // This prevents potential app crash when calling JSON.stringify // and when sending log strings remotely const userOptions = [ 'allowIncomingWhileBusy', 'appName', 'appVersion', 'closeProtection', 'codecPreferences', 'disableAudioContextSounds', 'dscp', 'edge', 'enableImprovedSignalingErrorPrecision', 'forceAggressiveIceNomination', 'logLevel', 'maxAverageBitrate', 'maxCallSignalingTimeoutMs', 'sounds', 'tokenRefreshMs', ]; const userOptionOverrides = [ 'RTCPeerConnection', 'enumerateDevices', 'getUserMedia', 'MediaStream', ]; if (typeof options === 'object') { const toLog = Object.assign({}, options); Object.keys(toLog).forEach((key) => { if (!userOptions.includes(key) && !userOptionOverrides.includes(key)) { delete toLog[key]; } if (userOptionOverrides.includes(key)) { toLog[key] = true; } }); this._log.debug(`.${caller}`, JSON.stringify(toLog)); } } /** * Create a new {@link Call}. * @param twimlParams - A flat object containing key:value pairs to be sent to the TwiML app. * @param options - Options to be used to instantiate the {@link Call}. */ _makeCall(twimlParams_1, options_1) { return __awaiter(this, arguments, void 0, function* (twimlParams, options, isReconnect = false) { var _a; if (typeof Device._isUnifiedPlanDefault === 'undefined') { throw new InvalidStateError('Device has not been initialized.'); } // Wait for the input device if it's set by the user const inputDevicePromise = (_a = this._audio) === null || _a === void 0 ? void 0 : _a._getInputDevicePromise(); if (inputDevicePromise) { this._log.debug('inputDevicePromise detected, waiting...'); yield inputDevicePromise; this._log.debug('inputDevicePromise resolved'); } const config = { audioHelper: this._audio, isUnifiedPlanDefault: Device._isUnifiedPlanDefault, onIgnore: () => { this._soundcache.get(Device.SoundName.Incoming).stop(); }, pstream: yield (this._streamConnectedPromise || this._setupStream()), publisher: this._publisher, soundcache: this._soundcache, }; options = Object.assign({ MediaStream: this._options.MediaStream, RTCPeerConnection: this._options.RTCPeerConnection, beforeAccept: (currentCall) => { if (!this._activeCall || this._activeCall === currentCall) { return; } this._activeCall.disconnect(); this._removeCall(this._activeCall); }, codecPreferences: this._options.codecPreferences, customSounds: this._options.sounds, dialtonePlayer: Device._dialtonePlayer, dscp: this._options.dscp, // TODO(csantos): Remove forceAggressiveIceNomination option in 3.x forceAggressiveIceNomination: this._options.forceAggressiveIceNomination, getInputStream: () => this._options.fileInputStream || this._callInputStream, getSinkIds: () => this._callSinkIds, maxAverageBitrate: this._options.maxAverageBitrate, preflight: this._options.preflight, rtcConstraints: this._options.rtcConstraints, shouldPlayDisconnect: () => { var _a; return (_a = this._audio) === null || _a === void 0 ? void 0 : _a.disconnect(); }, twimlParams, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }, options); const maybeUnsetPreferredUri = () => { if (!this._stream) { this._log.warn('UnsetPreferredUri called without a stream'); return; } if (this._activeCall === null && this._calls.length === 0) { this._stream.updatePreferredURI(null); } }; const call = new (this._options.Call || Call)(config, options); this._publisher.info('settings', 'init', { MediaStream: !!this._options.MediaStream, RTCPeerConnection: !!this._options.RTCPeerConnection, enumerateDevices: !!this._options.enumerateDevices, getUserMedia: !!this._options.getUserMedia, }, call); call.once('accept', () => { var _a, _b, _c; this._stream.updatePreferredURI(this._preferredURI); this._removeCall(call); this._activeCall = call; if (this._audio) { this._audio._maybeStartPollingVolume(); } if (call.direction === Call.CallDirection.Outgoing && ((_a = this._audio) === null || _a === void 0 ? void 0 : _a.outgoing()) && !isReconnect) { this._soundcache.get(Device.SoundName.Outgoing).play(); } const data = { edge: this._edge || this._region }; if (this._options.edge) { data['selected_edge'] = Array.isArray(this._options.edge) ? this._options.edge : [this._options.edge]; } this._publisher.info('settings', 'edge', data, call); if ((_b = this._audio) === null || _b === void 0 ? void 0 : _b.processedStream) { (_c = this._audioProcessorEventObserver) === null || _c === void 0 ? void 0 : _c.emit('enabled'); } }); call.addListener('error', (error) => { if (call.status() === 'closed') { this._removeCall(call); maybeUnsetPreferredUri(); } if (this._audio) { this._audio._maybeStopPollingVolume(); } this._maybeStopIncomingSound(); }); call.once('cancel', () => { this._log.info(`Canceled: ${call.parameters.CallSid}`); this._removeCall(call); maybeUnsetPreferredUri(); if (this._audio) { this._audio._maybeStopPollingVolume(); } this._maybeStopIncomingSound(); }); call.once('disconnect', () => { if (this._audio) { this._audio._maybeStopPollingVolume(); } this._removeCall(call); maybeUnsetPreferredUri(); /** * NOTE(kamalbennani): We need to stop the incoming sound when the call is * disconnected right after the user has accepted the call (activeCall.accept()), and before * the call has been fully connected (i.e. before the `pstream.answer` event) */ this._maybeStopIncomingSound(); }); call.once('reject', () => { this._log.info(`Rejected: ${call.parameters.CallSid}`); if (this._audio) { this._audio._maybeStopPollingVolume(); } this._removeCall(call); maybeUnsetPreferredUri(); this._maybeStopIncomingSound(); }); call.on('transportClose', () => { if (call.status() !== Call.State.Pending) { return; } if (this._audio) { this._audio._maybeStopPollingVolume(); } this._removeCall(call); /** * NOTE(mhuynh): We don't want to call `maybeUnsetPreferredUri` because * a `transportClose` will happen during signaling reconnection. */ this._maybeStopIncomingSound(); }); return call; }); } /** * Stop the incoming sound if no {@link Call}s remain. */ _maybeStopIncomingSound() { if (!this._calls.length) { this._soundcache.get(Device.SoundName.Incoming).stop(); } } /** * Remove a {@link Call} from device.calls by reference * @param call */ _removeCall(call) { if (this._activeCall === call) { this._activeCall = null; this._makeCallPromise = null; } for (let i = this._calls.length - 1; i >= 0; i--) { if (call === this._calls[i]) { this._calls.splice(i, 1); } } } /** * Register with the signaling server. */ _sendPresence(presence) { return __awaiter(this, void 0, void 0, function* () { const stream = yield this._streamConnectedPromise; if (!stream) { return; } stream.register({ audio: presence }); if (presence) { this._startRegistrationTimer(); } else { this._stopRegistrationTimer(); } }); } /** * Helper function that sets and emits the state of the device. * @param state The new state of the device. */ _setState(state) { if (state === this.state) { return; } this._state = state; const name = this._stateEventMapping[state]; this._log.debug(`#${name}`); this.emit(name); } /** * Set up an audio helper for usage by this {@link Device}. */ _setupAudioHelper() { if (!this._audioProcessorEventObserver) { this._audioProcessorEventObserver = new AudioProcessorEventObserver(); this._audioProcessorEventObserver.on('event', ({ name, group }) => { this._publisher.info(group, name, {}, this._activeCall); }); } const audioOptions = { audioContext: Device.audioContext, audioProcessorEventObserver: this._audioProcessorEventObserver, beforeSetInputDevice: () => { if (this._makeCallPromise) { this._log.debug('beforeSetInputDevice pause detected'); return this._makeCallPromise; } else { this._log.debug('beforeSetInputDevice pause not detected, setting default'); return Promise.resolve(); } }, enumerateDevices: this._options.enumerateDevices, getUserMedia: this._options.getUserMedia || getUserMedia, }; if (this._audio) { this._log.info('Found existing audio helper; updating options...'); this._audio._updateUserOptions(audioOptions); return; } this._audio = new (this._options.AudioHelper || AudioHelper)(this._updateSinkIds, this._updateInputStream, audioOptions); this._audio.on('deviceChange', (lostActiveDevices) => { const activeCall = this._activeCall; const deviceIds = lostActiveDevices.map((device) => device.deviceId); this._publisher.info('audio', 'device-change', { lost_active_device_ids: deviceIds, }, activeCall); if (activeCall) { activeCall['_mediaHandler']._onInputDevicesChanged(); } }); } /** * Setup logger's loglevel */ _setupLoglevel(logLevel) { const level = typeof logLevel === 'number' || typeof logLevel === 'string' ? logLevel : LogLevels.ERROR; this._log.setDefaultLevel(level); this._log.info('Set logger default level to', level); } /** * Create and set a publisher for the {@link Device} to use. */ _setupPublisher() { if (this._publisher) { this._log.info('Found existing publisher; destroying...'); this._destroyPublisher(); } const publisherOptions = { defaultPayload: this._createDefaultPayload, metadata: { app_name: this._options.appName, app_version: this._options.appVersion, }, }; if (this._options.eventgw) { publisherOptions.host = this._options.eventgw; } if (this._home) { publisherOptions.host = createEventGatewayURI(this._home); } this._publisher = new (this._options.Publisher || Publisher)(PUBLISHER_PRODUCT_NAME, this.token, publisherOptions); if (this._options.publishEvents === false) { this._publisher.disable(); } else { this._publisher.on('error', (error) => { this._log.warn('Cannot connect to insights.', error); }); } return this._publisher; } /** * Set up the connection to the signaling server. Tears down an existing * stream if called while a stream exis