@twilio/voice-sdk
Version:
Twilio's JavaScript Voice SDK
1,649 lines (1,409 loc) • 66.6 kB
text/typescript
import { EventEmitter } from 'events';
import { levels as LogLevels, LogLevelDesc } 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,
TwilioError,
} from './errors';
import Publisher from './eventpublisher';
import Log from './log';
import { PreflightTest } from './preflight/preflight';
import PStream from './pstream';
import {
createEventGatewayURI,
createSignalingEndpointURL,
Edge,
getChunderURIs,
getRegionShortcode,
Region,
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';
// Placeholders until we convert the respective files to TypeScript.
/**
* @private
*/
export type IPStream = any;
/**
* @private
*/
export type IPublisher = any;
/**
* @private
*/
export type ISound = any;
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".';
declare const RTCRtpTransceiver: any;
declare const webkitAudioContext: typeof AudioContext;
/**
* Options that may be passed to the {@link Device} constructor for internal testing.
* @private
*/
export interface IExtendedDeviceOptions extends Device.Options {
/**
* Custom {@link AudioHelper} constructor
*/
AudioHelper?: typeof AudioHelper;
/**
* The max amount of time in milliseconds to allow stream (re)-connect
* backoffs.
*/
backoffMaxMs?: number;
/**
* Custom {@link Call} constructor
*/
Call?: typeof Call;
/**
* Hostname of the signaling gateway to connect to.
*/
chunderw?: string | string[];
/**
* Hostname of the event gateway to connect to.
*/
eventgw?: string;
/**
* File input stream to use instead of reading from mic
*/
fileInputStream?: MediaStream;
/**
* Ignore browser support, disabling the exception that is thrown when neither WebRTC nor
* ORTC are supported.
*/
ignoreBrowserSupport?: boolean;
/**
* Whether this is a preflight call or not
*/
preflight?: boolean;
/**
* Custom PStream constructor
*/
PStream?: IPStream;
/**
* Custom Publisher constructor
*/
Publisher?: IPublisher;
/**
* Whether or not to publish events to insights using {@link Device._publisher}.
*/
publishEvents?: boolean;
/**
* MediaStreamConstraints to pass to getUserMedia when making or accepting a Call.
*/
rtcConstraints?: Call.AcceptOptions['rtcConstraints'];
/**
* Custom Sound constructor
*/
Sound?: ISound;
/**
* Voice event SID generator.
*/
voiceEventSidGenerator?: () => string;
}
/**
* A sound definition used to initialize a Sound file.
* @private
*/
export interface ISoundDefinition {
/**
* Name of the sound file.
*/
filename: string;
/**
* The amount of time this sound file should play before being stopped automatically.
*/
maxDuration?: number;
/**
* Whether or not this sound should loop after playthrough finishes.
*/
shouldLoop?: boolean;
}
/**
* 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(): AudioContext | undefined {
return Device._audioContext;
}
/**
* Which sound file extension is supported.
* @private
*/
static get extension(): 'mp3' | 'ogg' {
// NOTE(mroberts): Node workaround.
const a: any = 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(): boolean { return rtc.enabled(); }
/**
* Package name of the SDK.
*/
static get packageName(): string { 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: string, options?: PreflightTest.Options): PreflightTest {
return new PreflightTest(token, { audioContext: Device._getOrCreateAudioContext(), ...options });
}
/**
* String representation of {@link Device} class.
* @private
*/
static toString(): string {
return '[Twilio.Device class]';
}
/**
* Current SDK version.
*/
static get version(): string { return C.RELEASE_VERSION; }
/**
* An AudioContext to share between {@link Device}s.
*/
private static _audioContext?: AudioContext;
private static _defaultSounds: Record<string, ISoundDefinition> = {
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 },
};
/**
* A DialtonePlayer to play mock DTMF sounds through.
*/
private static _dialtonePlayer?: DialtonePlayer;
/**
* Whether or not the browser uses unified-plan SDP by default.
*/
private static _isUnifiedPlanDefault: boolean | undefined;
/**
* Initializes the AudioContext instance shared across the Voice SDK,
* or returns the existing instance if one has already been initialized.
*/
private static _getOrCreateAudioContext(): AudioContext | undefined {
if (!Device._audioContext) {
if (typeof AudioContext !== 'undefined') {
Device._audioContext = new AudioContext();
} else if (typeof webkitAudioContext !== 'undefined') {
Device._audioContext = new webkitAudioContext();
}
}
return Device._audioContext;
}
/**
* The currently active {@link Call}, if there is one.
*/
private _activeCall: Call | null = null;
/**
* The AudioHelper instance associated with this {@link Device}.
*/
private _audio: AudioHelper | null = null;
/**
* The AudioProcessorEventObserver instance to use
*/
private _audioProcessorEventObserver: AudioProcessorEventObserver | null = null;
/**
* {@link Device._confirmClose} bound to the specific {@link Device} instance.
*/
private _boundConfirmClose: typeof Device.prototype._confirmClose;
/**
* {@link Device.destroy} bound to the specific {@link Device} instance.
*/
private _boundDestroy: typeof Device.prototype.destroy;
/**
* An audio input MediaStream to pass to new {@link Call} instances.
*/
private _callInputStream: MediaStream | null = 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.
*/
private _calls: Call[] = [];
/**
* An array of {@link Device} IDs to be used to play sounds through, to be passed to
* new {@link Call} instances.
*/
private _callSinkIds: string[] = ['default'];
/**
* The list of chunder URIs that will be passed to PStream
*/
private _chunderURIs: string[] = [];
/**
* Default options used by {@link Device}.
*/
private readonly _defaultOptions: IExtendedDeviceOptions = {
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.
*/
private _edge: string | null = null;
/**
* The name of the home region the {@link Device} is connected to.
*/
private _home: string | null = null;
/**
* The identity associated with this Device.
*/
private _identity: string | null = null;
/**
* Whether SDK is run as a browser extension
*/
private _isBrowserExtension: boolean;
/**
* An instance of Logger to use.
*/
private _log: Log = new Log('Device');
/**
* The internal promise created when calling {@link Device.makeCall}.
*/
private _makeCallPromise: Promise<any> | null = null;
/**
* Network related information
* See https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
*/
private _networkInformation: any;
/**
* The options passed to {@link Device} constructor or {@link Device.updateOptions}.
*/
private _options: IExtendedDeviceOptions = { };
/**
* The preferred URI to (re)-connect signaling to.
*/
private _preferredURI: string | null = null;
/**
* An Insights Event Publisher.
*/
private _publisher: IPublisher | null = null;
/**
* The region the {@link Device} is connected to.
*/
private _region: string | null = null;
/**
* A timeout ID for a setTimeout schedule to re-register the {@link Device}.
*/
private _regTimer: NodeJS.Timeout | null = 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.
*/
private _shouldReRegister: boolean = false;
/**
* A Map of Sounds to play.
*/
private _soundcache: Map<Device.SoundName, ISound> = new Map();
/**
* The current status of the {@link Device}.
*/
private _state: Device.State = Device.State.Unregistered;
/**
* A map from {@link Device.State} to {@link Device.EventName}.
*/
private readonly _stateEventMapping: Record<Device.State, Device.EventName> = {
[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.
*/
private _stream: IPStream | null = null;
/**
* A promise that will resolve when the Signaling stream is ready.
*/
private _streamConnectedPromise: Promise<IPStream> | null = null;
/**
* The JWT string currently being used to authenticate this {@link Device}.
*/
private _token: string;
/**
* A timeout to track when the current AccessToken will expire.
*/
private _tokenWillExpireTimeout: NodeJS.Timeout | null = null;
/**
* 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: string, options: Device.Options = { }) {
super();
// 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 as IExtendedDeviceOptions).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: any = globalThis as any;
const browser: any = 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 as any;
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(): AudioHelper | null {
return this._audio;
}
/**
* Make an outgoing Call.
* @param options
*/
async connect(options: Device.ConnectOptions = { }): Promise<Call> {
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 {
throw new InvalidArgumentError('Cannot parse connectToken');
}
if (!parameters || !parameters.CallSid || !signalingReconnectToken) {
throw new InvalidArgumentError('Invalid connectToken');
}
}
let isReconnect = false;
let twimlParams: Record<string, string> = {};
const callOptions: Call.Options = {
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 = await 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(): Call[] {
return this._calls;
}
/**
* Destroy the {@link Device}, freeing references to be garbage collected.
*/
destroy(): void {
this._log.debug('.destroy');
this._log.debug('Rejecting any incoming calls');
const calls = this._calls.slice(0);
calls.forEach((call: Call) => call.reject());
this.disconnectAll();
this._stopRegistrationTimer();
this._destroyStream();
this._destroyAudioHelper();
this._audioProcessorEventObserver?.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(): void {
this._log.debug('.disconnectAll');
const calls = this._calls.splice(0);
calls.forEach((call: 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(): string | null {
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(): string | null {
return this._home;
}
/**
* Returns the identity associated with the {@link Device} for incoming calls. Only
* populated when registered.
*/
get identity(): string | null {
return this._identity;
}
/**
* Whether the Device is currently on an active Call.
*/
get isBusy(): boolean {
return !!this._activeCall;
}
/**
* Register the `Device` to the Twilio backend, allowing it to receive calls.
*/
async register(): Promise<void> {
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);
await (this._streamConnectedPromise || this._setupStream());
await this._sendPresence(true);
await promisifyEvents(this, Device.State.Registered, Device.State.Unregistered);
}
/**
* Get the state of this {@link Device} instance
*/
get state(): Device.State {
return this._state;
}
/**
* Get the token used by this {@link Device}.
*/
get token(): string | null {
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.
*/
async unregister(): Promise<void> {
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 = await this._streamConnectedPromise;
const streamOfflinePromise = new Promise(resolve => {
stream.on('offline', resolve);
});
await this._sendPresence(false);
await streamOfflinePromise;
}
/**
* Set the options used within the {@link Device}.
* @param options
*/
updateOptions(options: Device.Options = { }): void {
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 = { ...this._defaultOptions, ...this._options, ...options };
const originalChunderURIs: Set<string> = 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: ISoundDefinition = Device._defaultSounds[name];
const defaultUrl: string = `${C.SOUNDS_BASE_URL}/${soundDef.filename}.${Device.extension}`
+ `?cache=${C.RELEASE_VERSION}`;
const soundUrl: string = this._options.sounds && this._options.sounds[name as Device.SoundName] || defaultUrl;
const sound: any = new (this._options.Sound || Sound)(name, soundUrl, {
audioContext: this._options.disableAudioContextSounds ? null : Device.audioContext,
maxDuration: soundDef.maxDuration,
shouldLoop: soundDef.shouldLoop,
});
this._soundcache.set(name as Device.SoundName, 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: string) {
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
*/
private _confirmClose(event: any): string {
if (!this._activeCall) { return ''; }
const closeProtection: boolean | string = this._options.closeProtection || false;
const confirmationMsg: string = 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;
}
/**
* Create the default Insights payload
* @param call
*/
private _createDefaultPayload = (call?: Call): Record<string, any> => {
const payload: Record<string, any> = {
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: string, value: string | undefined | null) {
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;
}
/**
* Destroy the AudioHelper.
*/
private _destroyAudioHelper() {
if (!this._audio) { return; }
this._audio._destroy();
this._audio = null;
}
/**
* Destroy the publisher.
*/
private _destroyPublisher() {
// Attempt to destroy non-existent publisher.
if (!this._publisher) { return; }
this._publisher = null;
}
/**
* Destroy the connection to the signaling server.
*/
private _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
*/
private _findCall(callSid: string): Call | null {
return this._calls.find(call => call.parameters.CallSid === callSid
|| call.outboundConnectionId === callSid) || null;
}
/**
* Get chunderws array from the chunderw param
*/
private _getChunderws(): string[] | null {
return typeof this._options.chunderw === 'string' ? [this._options.chunderw]
: Array.isArray(this._options.chunderw) ? this._options.chunderw : null;
}
/**
* Utility function to log device options
*/
private _logOptions(caller: string, options: Device.Options = { }): void {
// 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: any = { ...options };
Object.keys(toLog).forEach((key: string) => {
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}.
*/
private async _makeCall(twimlParams: Record<string, string>, options?: Call.Options, isReconnect: boolean = false): Promise<Call> {
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 = this._audio?._getInputDevicePromise();
if (inputDevicePromise) {
this._log.debug('inputDevicePromise detected, waiting...');
await inputDevicePromise;
this._log.debug('inputDevicePromise resolved');
}
const config: Call.Config = {
audioHelper: this._audio,
isUnifiedPlanDefault: Device._isUnifiedPlanDefault,
onIgnore: (): void => {
this._soundcache.get(Device.SoundName.Incoming).stop();
},
pstream: await (this._streamConnectedPromise || this._setupStream()),
publisher: this._publisher,
soundcache: this._soundcache,
};
options = Object.assign({
MediaStream: this._options.MediaStream,
RTCPeerConnection: this._options.RTCPeerConnection,
beforeAccept: (currentCall: Call) => {
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: (): MediaStream | null => this._options.fileInputStream || this._callInputStream,
getSinkIds: (): string[] => this._callSinkIds,
maxAverageBitrate: this._options.maxAverageBitrate,
preflight: this._options.preflight,
rtcConstraints: this._options.rtcConstraints,
shouldPlayDisconnect: () => this._audio?.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', () => {
this._stream.updatePreferredURI(this._preferredURI);
this._removeCall(call);
this._activeCall = call;
if (this._audio) {
this._audio._maybeStartPollingVolume();
}
if (call.direction === Call.CallDirection.Outgoing && this._audio?.outgoing() && !isReconnect) {
this._soundcache.get(Device.SoundName.Outgoing).play();
}
const data: any = { 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 (this._audio?.processedStream) {
this._audioProcessorEventObserver?.emit('enabled');
}
});
call.addListener('error', (error: TwilioError) => {
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.
*/
private _maybeStopIncomingSound(): void {
if (!this._calls.length) {
this._soundcache.get(Device.SoundName.Incoming).stop();
}
}
/**
* Called when a 'close' event is received from the signaling stream.
*/
private _onSignalingClose = () => {
this._stream = null;
this._streamConnectedPromise = null;
}
/**
* Called when a 'connected' event is received from the signaling stream.
*/
private _onSignalingConnected = (payload: Record<string, any>) => {
const region = getRegionShortcode(payload.region);
this._edge = payload.edge || regionToEdge[region as Region] || payload.region;
this._region = region || payload.region;
this._home = payload.home;
this._publisher?.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: number = payload.token.ttl * 1000;
const timeoutMs: number = 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 as 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.
*/
private _onSignalingError = (payload: Record<string, any>) => {
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: Call | undefined =
(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.
*/
private _onSignalingInvite = async (payload: Record<string, any>) => {
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 = await this._makeCallPromise;
} finally {
this._makeCallPromise = null;
}
this._calls.push(call);
call.once('accept', () => {
this._soundcache.get(Device.SoundName.Incoming).stop();
this._publishNetworkChange();
});
const play = (this._audio?.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.
*/
private _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.
*/
private _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}.
*/
private _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);
}
}
/**
* Remove a {@link Call} from device.calls by reference
* @param call
*/
private _removeCall(call: Call): void {
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.
*/
private async _sendPresence(presence: boolean): Promise<void> {
const stream = await 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.
*/
private _setState(state: Device.State): void {
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}.
*/
private _setupAudioHelper(): void {
if (!this._audioProcessorEventObserver) {
this._audioProcessorEventObserver = new AudioProcessorEventObserver();
this._audioProcessorEventObserver.on('event', ({ name, group }) => {
this._publisher.info(group, name, {}, this._activeCall);
});
}
const audioOptions: AudioHelper.Options = {
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: MediaDeviceInfo[]) => {
const activeCall: Call | null = this._activeCall;
const deviceIds: string[] = lostActiveDevices.map((device: MediaDeviceInfo) => device.deviceId);
this._publisher.info('audio', 'device-change', {
lost_active_device_ids: deviceIds,
}, activeCall);
if (activeCall) {
activeCall['_mediaHandler']._onInputDevicesChanged();
}
});
}
/**
* Setup logger's loglevel
*/
private _setupLoglevel(logLevel?: LogLevelDesc): void {
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.
*/
private _setupPublisher(): IPublisher {
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,
},
} as any;
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: 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.
*/
private _setupStream(): Promise<IPStream> {
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 =
promisifyEvents(this._stream, 'connected', 'close').then(() => 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.
*/
private _showIncomingCall(call: Call, play: Function): Promise<void> {
let timeout: NodeJS.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.warn(reason.message);
}).then(() => {
clearTimeout(timeout);
this._log.debug('#incoming', JSON.stringify({
customParameters: call.customParameters,
parameters: call.parameters,
}));
this.emit(Device.EventName.Incoming, call);
});
}
/**
* Set a timeout to send another register message to the signaling server.
*/
private _startRegistrationTimer(): void {
this._stopRegistrationTimer();
this._regTimer = setTimeout(() => {
this._sendPresence(true);
}, REGISTRATION_INTERVAL);
}
/**
* Stop sending registration messages to the signaling server.
*/
private _stopRegistrationTimer(): void {
if (this._regTimer) {
clearTimeout(this._regTimer);
}
}
/**
* Throw an error if the {@link Device} is destroyed.
*/
private _throwIfDestroyed(): void {
if (this.state === Device.State.Destroyed) {
throw new InvalidStateError('Device has been destroyed.');
}
}
/**
* 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
*/
private _updateInputStream = (inputStream: MediaStream | null): Promise<void> => {
const call: Call | null = 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 the incoming ringtone through.
* @param sinkIds - An array of device IDs
*/
private _updateRingtoneSinkIds(sinkIds: string[]): Promise<void> {
return Promise.resolve(this._soundcache.get(Device.SoundName