UNPKG

@twilio/voice-sdk

Version:
1,208 lines 93.4 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()); }); }; /** * @packageDocumentation * @module Voice * @preferred * @publicapi */ import { EventEmitter } from 'events'; import { levels as LogLevels } from 'loglevel'; import AudioHelper from './audiohelper'; import Call from './call'; import * as C from './constants'; import DialtonePlayer from './dialtonePlayer'; import { AuthorizationErrors, ClientErrors, GeneralErrors, getErrorByCode, hasErrorByCode, 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 Sound from './sound'; import { isLegacyEdge, isUnifiedPlanDefault, queryToJson, } from './util'; import { generateVoiceEventSid } from './uuid'; 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. * @publicapi */ class Device extends EventEmitter { /** * Construct a {@link Device} instance. The {@link Device} can be registered * to make and listen for calls using {@link Device.register}. * @constructor * @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; /** * 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, 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 = Log.getInstance(); /** * 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.emit('tokenWillExpire', this); if (this._tokenWillExpireTimeout) { clearTimeout(this._tokenWillExpireTimeout); this._tokenWillExpireTimeout = null; } }, timeoutMs); } } const preferredURIs = getChunderURIs(this._edge); if (preferredURIs.length > 0) { const [preferredURI] = preferredURIs; this._preferredURI = createSignalingEndpointURL(preferredURI); } else { this._log.info('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') { return; } const { error: originalError, callsid } = payload; if (typeof originalError !== 'object') { 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 if (hasErrorByCode(code)) { twilioError = new (getErrorByCode(code))(originalError); } } if (!twilioError) { this._log.error('Unknown signaling error: ', originalError); twilioError = new GeneralErrors.UnknownError(customMessage, originalError); } this._log.info('Received error: ', twilioError); 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.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)); const call = yield this._makeCall(customParameters, { callParameters, offerSdp: payload.sdp, reconnectToken: payload.reconnect, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }); 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; }); }; 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>.`); } if (window) { const root = window; 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); } /** * 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; } /** * Return the {@link AudioHelper} used by this {@link Device}. */ get audio() { return this._audio; } /** * Make an outgoing Call. * @param options */ connect(options = {}) { return __awaiter(this, void 0, void 0, function* () { this._throwIfDestroyed(); if (this._activeCall) { throw new InvalidStateError('A Call is already active'); } const activeCall = this._activeCall = yield this._makeCall(options.params || {}, { rtcConfiguration: options.rtcConfiguration, voiceEventSidGenerator: this._options.voiceEventSidGenerator, }); // 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() { this.disconnectAll(); this._stopRegistrationTimer(); if (this._audio) { this._audio._unbind(); } this._destroyStream(); this._destroyPublisher(); this._destroyAudioHelper(); 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() { 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* () { 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._setState(Device.State.Registering); const stream = yield (this._streamConnectedPromise || this._setupStream()); const streamReadyPromise = new Promise(resolve => { this.once(Device.State.Registered, resolve); }); yield this._sendPresence(true); yield streamReadyPromise; }); } /** * 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* () { 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 = {}) { 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 chunderw = typeof this._options.chunderw === 'string' ? [this._options.chunderw] : Array.isArray(this._options.chunderw) && this._options.chunderw; const newChunderURIs = this._chunderURIs = (chunderw || 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._log.setDefaultLevel(typeof this._options.logLevel === 'number' ? this._options.logLevel : LogLevels.ERROR); if (this._options.dscp) { if (!this._options.rtcConstraints) { this._options.rtcConstraints = {}; } this._options.rtcConstraints.optional = [{ googDscp: true }]; } 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) { 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.removeAllListeners(); 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; } /** * 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, options) { return __awaiter(this, void 0, void 0, function* () { if (typeof Device._isUnifiedPlanDefault === 'undefined') { throw new InvalidStateError('Device has not been initialized.'); } const config = { audioHelper: this._audio, getUserMedia: this._options.getUserMedia || getUserMedia, 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 || rtc.PeerConnection, 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', { RTCPeerConnection: !!this._options.RTCPeerConnection, enumerateDevices: !!this._options.enumerateDevices, getUserMedia: !!this._options.getUserMedia, }, call); call.once('accept', () => { var _a; 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())) { 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); }); 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; } 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; this.emit(this._stateEventMapping[state]); } /** * Set up an audio helper for usage by this {@link Device}. */ _setupAudioHelper() { const audioOptions = { audioContext: Device.audioContext, enumerateDevices: this._options.enumerateDevices, }; if (this._audio) { this._log.info('Found existing audio helper; destroying...'); audioOptions.enabledSounds = this._audio._getEnabledSounds(); this._destroyAudioHelper(); } this._audio = new (this._options.AudioHelper || AudioHelper)(this._updateSinkIds, this._updateInputStream, this._options.getUserMedia || getUserMedia, 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(); } }); } /** * 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, log: this._log, 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 exists. */ _setupStream() { if (this._stream) { this._log.info('Found existing stream; destroying...'); this._destroyStream(); } this._log.info('Setting up VSP'); this._stream = new (this._options.PStream || PStream)(this.token, this._chunderURIs, { backoffMaxMs: this._options.backoffMaxMs, maxPreferredDurationMs: this._options.maxCallSignalingTimeoutMs, }); this._stream.addListener('close', this._onSignalingClose); this._stream.addListener('connected', this._onSignalingConnected); this._stream.addListener('error', this._onSignalingError); this._stream.addListener('invite', this._onSignalingInvite); this._stream.addListener('offline', this._onSignalingOffline); this._stream.addListener('ready', this._onSignalingReady); return this._streamConnectedPromise = new Promise(resolve => this._stream.once('connected', () => { resolve(this._stream); })); } /** * Start playing the incoming ringtone, and subsequently emit the incoming event. * @param call * @param play - The function to be used to play the sound. Must return a Promise. */ _showIncomingCall(call, play) { let timeout; return Promise.race([ play(), new Promise((resolve, reject) => { timeout = setTimeout(() => { const msg = 'Playing incoming ringtone took too long; it might not play. Continuing execution...'; reject(new Error(msg)); }, RINGTONE_PLAY_TIMEOUT); }), ]).catch(reason => { this._log.info(reason.message); }).then(() => { clearTimeout(timeout); this.emit(Device.EventName.Incoming, call); }); } /** * Set a timeout to send another register message to the signaling server. */ _startRegistrationTimer() { this._stopRegistrationTimer(); this._regTimer = setTimeout(() => { this._sendPresence(true); }, REGISTRATION_INTERVAL); } /** * Stop sending registration messages to the signaling server. */ _stopRegistrationTimer() { if (this._regTimer) { clearTimeout(this._regTimer); } } /** * Throw an error if the {@link Device} is destroyed. */ _throwIfDestroyed() { if (this.state === Device.State.Destroyed) { throw new InvalidStateError('Device has been destroyed.'); } } /** * Update the device IDs of output devices being used to play the incoming ringtone through. * @param sinkIds - An array of device IDs */ _updateRingtoneSinkIds(sinkIds) { return Promise.resolve(this._soundcache.get(Device.SoundName.Incoming).setSinkIds(sinkIds)); } /** * Update the device IDs of output devices being used to play the non-ringtone sounds * and Call audio through. * @param sinkIds - An array of device IDs */ _updateSpeakerSinkIds(sinkIds) { Array.from(this._soundcache.entries()) .filter(entry => entry[0] !== Device.SoundName.Incoming) .forEach(entry => entry[1].setSinkIds(sinkIds)); this._callSinkIds = sinkIds; const call = this._activeCall; return call ? call._setSinkIds(sinkIds) : Promise.resolve(); } } Device._defaultSounds = { disconnect: { filename: 'disconnect', maxDuration: 3000 }, dtmf0: { filename: 'dtmf-0', maxDuration: 1000 }, dtmf1: { filename: 'dtmf-1', maxDuration: 1000 }, dtmf2: { filename: 'dtmf-2', maxDuration: 1000 }, dtmf3: { filename: 'dtmf-3', maxDuration: 1000 }, dtmf4: { filename: 'dtmf-4', maxDuration: 1000 }, dtmf5: { filename: 'dtmf-5', maxDuration: 1000 }, dtmf6: { filename: 'dtmf-6', maxDuration: 1000 }, dtmf7: { filename: 'dtmf-7', maxDuration: 1000 }, dtmf8: { filename: 'dtmf-8', maxDuration: 1000 }, dtmf9: { filename: 'dtmf-9', maxDuration: 1000 }, dtmfh: { filename: 'dtmf-hash', maxDuration: 1000 }, dtmfs: { filename: 'dtmf-star', maxDuration: 1000 }, incoming: { filename: 'incoming', shouldLoop: true }, outgoing: { filename: 'outgoing', maxDuration: 3000 }, }; (function (Device) { /** * All valid {@link Device} event names. */ let EventName; (function (EventName) { EventName["Error"] = "error"; EventName["Incoming"] = "incoming"; EventName["Destroyed"] = "destroyed"; EventName["Unregistered"] = "unregistered"; EventName["Registering"] = "registering"; EventName["Registered"] = "registered"; EventName["TokenWillExpire"] = "tokenWillExpire"; })(EventName = Device.EventName || (Device.EventName = {})); /** * All possible {@link Device} states. */ let State; (function (State) { State["Destroyed"] = "destroyed"; State["Unregistered"] = "unregistered"; State["Registering"] = "registering"; State["Registered"] = "registered"; })(State = Device.State || (Device.State = {})); /** * Names of all sounds handled by the {@link Device}. */ let SoundName; (function (SoundName) { SoundName["Incoming"] = "incoming"; SoundName["Outgoing"] = "outgoing"; SoundName["Disconnect"] = "disconnect"; SoundName["Dtmf0"] = "dtmf0"; SoundName["Dtmf1"] = "dtmf1"; SoundName["Dtmf2"] = "dtmf2"; SoundName["Dtmf3"] = "dtmf3"; SoundName["Dtmf4"] = "dtmf4"; SoundName["Dtmf5"] = "dtmf5"; SoundName["Dtmf6"] = "dtmf6"; SoundName["Dtmf7"] = "dtmf7"; SoundName["Dtmf8"] = "dtmf8"; SoundName["Dtmf9"] = "dtmf9"; SoundName["DtmfS"] = "dtmfs"; SoundName["DtmfH"] = "dtmfh"; })(SoundName = Device.SoundName || (Device.SoundName = {})); })(Device || (Device = {})); export default Device; //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGV2aWNlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vbGliL3R3aWxpby9kZXZpY2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7O0FBQUE7Ozs7O0dBS0c7QUFDSCxPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sUUFBUSxDQUFDO0FBQ3RDLE9BQU8sRUFBRSxNQUFNLElBQUksU0FBUyxFQUFnQixNQUFNLFVBQVUsQ0FBQztBQUM3RCxPQUFPLFdBQVcsTUFBTSxlQUFlLENBQUM7QUFDeEMsT0FBTyxJQUFJLE1BQU0sUUFBUSxDQUFDO0FBQzFCLE9BQU8sS0FBSyxDQUFDLE1BQU0sYUFBYSxDQUFDO0FBQ2pDLE9BQU8sY0FBYyxNQUFNLGtCQUFrQixDQUFDO0FBQzlDLE9BQU8sRUFDTCxtQkFBbUIsRUFDbkIsWUFBWSxFQUNaLGFBQWEsRUFDYixjQUFjLEVBQ2QsY0FBYyxFQUNkLG9CQUFvQixFQUNwQixpQkFBaUIsRUFDakIsaUJBQWlCLEdBRWxCLE1BQU0sVUFBVSxDQUFDO0FBQ2xCLE9BQU8sU0FBUyxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sR0FBRyxNQUFNLE9BQU8sQ0FBQztBQUN4QixPQUFPLEVBQUUsYUFBYSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDdEQsT0FBTyxPQUFPLE1BQU0sV0