@twilio/voice-sdk
Version:
Twilio's JavaScript Voice SDK
1,202 lines (1,201 loc) • 106 kB
JavaScript
import { __awaiter } from 'tslib';
import { EventEmitter } from 'events';
import * as loglevel from 'loglevel';
import AudioHelper from './audiohelper.js';
import { AudioProcessorEventObserver } from './audioprocessoreventobserver.js';
import Call from './call.js';
import { PACKAGE_NAME, RELEASE_VERSION, SOUNDS_BASE_URL } from './constants.js';
import DialtonePlayer from './dialtonePlayer.js';
import { getPreciseSignalingErrorByCode, InvalidStateError, NotSupportedError, InvalidArgumentError } from './errors/index.js';
import EventPublisher from './eventpublisher.js';
import Log from './log.js';
import { PreflightTest } from './preflight/preflight.js';
import PStream from './pstream.js';
import { getRegionShortcode, regionToEdge, createEventGatewayURI, getChunderURIs, createSignalingEndpointURL } from './regions.js';
import { enabled, getMediaEngine } from './rtc/index.js';
import getUserMedia from './rtc/getusermedia.js';
import { generateVoiceEventSid } from './sid.js';
import Sound from './sound.js';
import { queryToJson, isLegacyEdge, promisifyEvents } from './util.js';
import { AuthorizationErrors, GeneralErrors, ClientErrors } from './errors/generated.js';
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 enabled(); }
/**
* Package name of the SDK.
*/
static get packageName() { return 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 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: loglevel.levels.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: getMediaEngine(),
sdk_version: 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);
}
}
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 = `${SOUNDS_BASE_URL}/${soundDef.filename}.${Device.extension}`
+ `?cache=${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;
// 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,
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, _d, _e;
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.localProcessedStream) {
(_c = this._audioProcessorEventObserver) === null || _c === void 0 ? void 0 : _c.emit('enabled', false);
}
if ((_d = this._audio) === null || _d === void 0 ? void 0 : _d.remoteProcessedStream) {
(_e = this._audioProcessorEventObserver) === null || _e === void 0 ? void 0 : _e.emit('enabled', true);
}
});
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, isRemote }) => {
this._publisher.info(group, name, { is_remote: isRemote }, 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 : loglevel.levels.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 || EventPublisher)(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', thi