UNPKG

@twilio/voice-sdk

Version:
1,098 lines (1,095 loc) 118 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var tslib = require('tslib'); var events = require('events'); var backoff = require('./backoff.js'); var device = require('./device.js'); var index = require('./errors/index.js'); var log = require('./log.js'); var peerconnection = require('./rtc/peerconnection.js'); require('./rtc/rtcpc.js'); var icecandidate = require('./rtc/icecandidate.js'); var sdp = require('./rtc/sdp.js'); var sid = require('./sid.js'); var statsMonitor = require('./statsMonitor.js'); var util = require('./util.js'); var constants = require('./constants.js'); var generated = require('./errors/generated.js'); var BACKOFF_CONFIG = { factor: 1.1, jitter: 0.5, max: 30000, min: 1, }; var DTMF_INTER_TONE_GAP = 70; var DTMF_PAUSE_DURATION = 500; var DTMF_TONE_DURATION = 160; var METRICS_BATCH_SIZE = 10; var METRICS_DELAY = 5000; var MEDIA_DISCONNECT_ERROR = { disconnect: true, info: { code: 31003, message: 'Connection with Twilio was interrupted.', twilioError: new generated.MediaErrors.ConnectionError(), }, }; var MULTIPLE_THRESHOLD_WARNING_NAMES = { // The stat `packetsLostFraction` is monitored by two separate thresholds, // `maxAverage` and `max`. Each threshold emits a different warning name. packetsLostFraction: { max: 'packet-loss', maxAverage: 'packets-lost-fraction', }, }; var WARNING_NAMES = { audioInputLevel: 'audio-input-level', audioOutputLevel: 'audio-output-level', bytesReceived: 'bytes-received', bytesSent: 'bytes-sent', jitter: 'jitter', mos: 'mos', rtt: 'rtt', }; var WARNING_PREFIXES = { max: 'high-', maxAverage: 'high-', maxDuration: 'constant-', min: 'low-', minStandardDeviation: 'constant-', }; /** * A {@link Call} represents a media and signaling connection to a TwiML application. */ exports.default = /** @class */ (function (_super) { tslib.__extends(Call, _super); /** * @internal * @param config - Mandatory configuration options * @param options - Optional settings */ function Call(config, options) { var _this = _super.call(this) || this; /** * Call parameters received from Twilio for an incoming call. */ _this.parameters = {}; /** * The number of times input volume has been the same consecutively. */ _this._inputVolumeStreak = 0; /** * Whether the call has been answered. */ _this._isAnswered = false; /** * Whether the call has been cancelled. */ _this._isCancelled = false; /** * Whether the call has been rejected */ _this._isRejected = false; /** * The most recent public input volume value. 0 -> 1 representing -100 to -30 dB. */ _this._latestInputVolume = 0; /** * The most recent public output volume value. 0 -> 1 representing -100 to -30 dB. */ _this._latestOutputVolume = 0; /** * An instance of Logger to use. */ _this._log = new log.default('Call'); /** * State of the {@link Call}'s media. */ _this._mediaStatus = Call.State.Pending; /** * A map of messages sent via sendMessage API using voiceEventSid as the key. * The message will be deleted once an 'ack' or an error is received from the server. */ _this._messages = new Map(); /** * A batch of metrics samples to send to Insights. Gets cleared after * each send and appended to on each new sample. */ _this._metricsSamples = []; /** * Options passed to this {@link Call}. */ _this._options = { MediaHandler: peerconnection.default, MediaStream: null, enableImprovedSignalingErrorPrecision: false, offerSdp: null, shouldPlayDisconnect: function () { return true; }, voiceEventSidGenerator: sid.generateVoiceEventSid, }; /** * The number of times output volume has been the same consecutively. */ _this._outputVolumeStreak = 0; /** * Whether the {@link Call} should send a hangup on disconnect. */ _this._shouldSendHangup = true; /** * State of the {@link Call}'s signaling. */ _this._signalingStatus = Call.State.Pending; /** * A Map of Sounds to play. */ _this._soundcache = new Map(); /** * State of the {@link Call}. */ _this._status = Call.State.Pending; /** * Whether the {@link Call} has been connected. Used to determine if we are reconnected. */ _this._wasConnected = false; /** * String representation of {@link Call} instance. * @internal */ _this.toString = function () { return '[Twilio.Call instance]'; }; _this._emitWarning = function (groupPrefix, warningName, threshold, value, wasCleared, warningData) { var groupSuffix = wasCleared ? '-cleared' : '-raised'; var groupName = "".concat(groupPrefix, "warning").concat(groupSuffix); // Ignore constant input if the Call is muted (Expected) if (warningName === 'constant-audio-input-level' && _this.isMuted()) { return; } var level = wasCleared ? 'info' : 'warning'; // Avoid throwing false positives as warnings until we refactor volume metrics if (warningName === 'constant-audio-output-level') { level = 'info'; } var payloadData = { threshold: threshold }; if (value) { if (value instanceof Array) { payloadData.values = value.map(function (val) { if (typeof val === 'number') { return Math.round(val * 100) / 100; } return value; }); } else { payloadData.value = value; } } _this._publisher.post(level, groupName, warningName, { data: payloadData }, _this); if (warningName !== 'constant-audio-output-level') { var emitName = wasCleared ? 'warning-cleared' : 'warning'; _this._log.debug("#".concat(emitName), warningName); _this.emit(emitName, warningName, warningData && !wasCleared ? warningData : null); } }; /** * Called when the {@link Call} receives an ack from signaling * @param payload */ _this._onAck = function (payload) { var acktype = payload.acktype, callsid = payload.callsid, voiceeventsid = payload.voiceeventsid; if (_this.parameters.CallSid !== callsid) { _this._log.warn("Received ack from a different callsid: ".concat(callsid)); return; } if (acktype === 'message') { _this._onMessageSent(voiceeventsid); } }; /** * Called when the {@link Call} is answered. * @param payload */ _this._onAnswer = function (payload) { if (typeof payload.reconnect === 'string') { _this._signalingReconnectToken = payload.reconnect; } // answerOnBridge=false will send a 183 which we need to catch in _onRinging when // the enableRingingState flag is disabled. In that case, we will receive a 200 after // the callee accepts the call firing a second `accept` event if we don't // short circuit here. if (_this._isAnswered && _this._status !== Call.State.Reconnecting) { return; } _this._setCallSid(payload); _this._isAnswered = true; _this._maybeTransitionToOpen(); }; /** * Called when the {@link Call} is cancelled. * @param payload */ _this._onCancel = function (payload) { // (rrowland) Is this check necessary? Verify, and if so move to pstream / VSP module. var callsid = payload.callsid; if (_this.parameters.CallSid === callsid) { _this._isCancelled = true; _this._publisher.info('connection', 'cancel', null, _this); _this._cleanupEventListeners(); _this._mediaHandler.close(); _this._status = Call.State.Closed; _this._log.debug('#cancel'); _this.emit('cancel'); _this._pstream.removeListener('cancel', _this._onCancel); } }; /** * Called when we receive a connected event from pstream. * Re-emits the event. */ _this._onConnected = function () { _this._log.info('Received connected from pstream'); if (_this._signalingReconnectToken && _this._mediaHandler.version) { _this._pstream.reconnect(_this._mediaHandler.version.getSDP(), _this.parameters.CallSid, _this._signalingReconnectToken); } }; /** * Called when the {@link Call} is hung up. * @param payload */ _this._onHangup = function (payload) { if (_this.status() === Call.State.Closed) { return; } /** * see if callsid passed in message matches either callsid or outbound id * call should always have either callsid or outbound id * if no callsid passed hangup anyways */ if (payload.callsid && (_this.parameters.CallSid || _this.outboundConnectionId)) { if (payload.callsid !== _this.parameters.CallSid && payload.callsid !== _this.outboundConnectionId) { return; } } else if (payload.callsid) { // hangup is for another call return; } _this._log.info('Received HANGUP from gateway'); if (payload.error) { var code = payload.error.code; var errorConstructor = index.getPreciseSignalingErrorByCode(_this._options.enableImprovedSignalingErrorPrecision, code); var error = typeof errorConstructor !== 'undefined' ? new errorConstructor(payload.error.message) : new generated.GeneralErrors.ConnectionError('Error sent from gateway in HANGUP', payload.error); _this._log.error('Received an error from the gateway:', error); _this._log.debug('#error', error); _this.emit('error', error); } _this._shouldSendHangup = false; _this._publisher.info('connection', 'disconnected-by-remote', null, _this); _this._disconnect(null, true); _this._cleanupEventListeners(); }; /** * Called when there is a media failure. * Manages all media-related states and takes action base on the states * @param type - Type of media failure */ _this._onMediaFailure = function (type) { var _a = Call.MediaFailure, ConnectionDisconnected = _a.ConnectionDisconnected, ConnectionFailed = _a.ConnectionFailed, IceGatheringFailed = _a.IceGatheringFailed, LowBytes = _a.LowBytes; // These types signifies the end of a single ICE cycle var isEndOfIceCycle = type === ConnectionFailed || type === IceGatheringFailed; // All browsers except chrome doesn't update pc.iceConnectionState and pc.connectionState // after issuing an ICE Restart, which we use to determine if ICE Restart is complete. // Since we cannot detect if ICE Restart is complete, we will not retry. if (!util.isChrome(window, window.navigator) && type === ConnectionFailed) { return _this._mediaHandler.onerror(MEDIA_DISCONNECT_ERROR); } // Ignore subsequent requests if ice restart is in progress if (_this._mediaStatus === Call.State.Reconnecting) { // This is a retry. Previous ICE Restart failed if (isEndOfIceCycle) { // We already exceeded max retry time. if (Date.now() - _this._mediaReconnectStartTime > BACKOFF_CONFIG.max) { _this._log.warn('Exceeded max ICE retries'); return _this._mediaHandler.onerror(MEDIA_DISCONNECT_ERROR); } // Issue ICE restart with backoff try { _this._mediaReconnectBackoff.backoff(); } catch (error) { // Catch and ignore 'Backoff in progress.' errors. If a backoff is // ongoing and we try to start another one, there shouldn't be a // problem. if (!(error.message && error.message === 'Backoff in progress.')) { throw error; } } } return; } var pc = _this._mediaHandler.version.pc; var isIceDisconnected = pc && pc.iceConnectionState === 'disconnected'; var hasLowBytesWarning = _this._monitor.hasActiveWarning('bytesSent', 'min') || _this._monitor.hasActiveWarning('bytesReceived', 'min'); // Only certain conditions can trigger media reconnection if ((type === LowBytes && isIceDisconnected) || (type === ConnectionDisconnected && hasLowBytesWarning) || isEndOfIceCycle) { var mediaReconnectionError = new generated.MediaErrors.ConnectionError('Media connection failed.'); _this._log.warn('ICE Connection disconnected.'); _this._publisher.warn('connection', 'error', mediaReconnectionError, _this); _this._publisher.info('connection', 'reconnecting', null, _this); _this._mediaReconnectStartTime = Date.now(); _this._status = Call.State.Reconnecting; _this._mediaStatus = Call.State.Reconnecting; _this._mediaReconnectBackoff.reset(); _this._mediaReconnectBackoff.backoff(); _this._log.debug('#reconnecting'); _this.emit('reconnecting', mediaReconnectionError); } }; /** * Called when media call is restored */ _this._onMediaReconnected = function () { // Only trigger once. // This can trigger on pc.onIceConnectionChange and pc.onConnectionChange. if (_this._mediaStatus !== Call.State.Reconnecting) { return; } _this._log.info('ICE Connection reestablished.'); _this._mediaStatus = Call.State.Open; if (_this._signalingStatus === Call.State.Open) { _this._publisher.info('connection', 'reconnected', null, _this); _this._log.debug('#reconnected'); _this.emit('reconnected'); _this._status = Call.State.Open; } }; /** * Raised when a Call receives a message from the backend. * @param payload - A record representing the payload of the message from the * Twilio backend. */ _this._onMessageReceived = function (payload) { var callsid = payload.callsid, content = payload.content, contenttype = payload.contenttype, messagetype = payload.messagetype, voiceeventsid = payload.voiceeventsid; if (_this.parameters.CallSid !== callsid) { _this._log.warn("Received a message from a different callsid: ".concat(callsid)); return; } var data = { content: content, contentType: contenttype, messageType: messagetype, voiceEventSid: voiceeventsid, }; _this._publisher.info('call-message', messagetype, { content_type: contenttype, event_type: 'received', voice_event_sid: voiceeventsid, }, _this); _this._log.debug('#messageReceived', JSON.stringify(data)); _this.emit('messageReceived', data); }; /** * Raised when a Call receives an 'ack' with an 'acktype' of 'message. * This means that the message sent via sendMessage API has been received by the signaling server. * @param voiceEventSid */ _this._onMessageSent = function (voiceEventSid) { if (!_this._messages.has(voiceEventSid)) { _this._log.warn("Received a messageSent with a voiceEventSid that doesn't exists: ".concat(voiceEventSid)); return; } var message = _this._messages.get(voiceEventSid); _this._messages.delete(voiceEventSid); _this._publisher.info('call-message', message === null || message === void 0 ? void 0 : message.messageType, { content_type: message === null || message === void 0 ? void 0 : message.contentType, event_type: 'sent', voice_event_sid: voiceEventSid, }, _this); _this._log.debug('#messageSent', JSON.stringify(message)); _this.emit('messageSent', message); }; /** * When we get a RINGING signal from PStream, update the {@link Call} status. * @param payload */ _this._onRinging = function (payload) { _this._setCallSid(payload); // If we're not in 'connecting' or 'ringing' state, this event was received out of order. if (_this._status !== Call.State.Connecting && _this._status !== Call.State.Ringing) { return; } var hasEarlyMedia = !!payload.sdp; _this._status = Call.State.Ringing; _this._publisher.info('connection', 'outgoing-ringing', { hasEarlyMedia: hasEarlyMedia }, _this); _this._log.debug('#ringing'); _this.emit('ringing', hasEarlyMedia); }; /** * Called each time StatsMonitor emits a sample. * Emits stats event and batches the call stats metrics and sends them to Insights. * @param sample */ _this._onRTCSample = function (sample) { var callMetrics = tslib.__assign(tslib.__assign({}, sample), { inputVolume: _this._latestInputVolume, outputVolume: _this._latestOutputVolume }); _this._codec = callMetrics.codecName; _this._metricsSamples.push(callMetrics); if (_this._metricsSamples.length >= METRICS_BATCH_SIZE) { _this._publishMetrics(); } _this.emit('sample', sample); }; /** * Called when an 'error' event is received from the signaling stream. */ _this._onSignalingError = function (payload) { var callsid = payload.callsid, voiceeventsid = payload.voiceeventsid, error = payload.error; if (_this.parameters.CallSid !== callsid) { _this._log.warn("Received an error from a different callsid: ".concat(callsid)); return; } if (voiceeventsid && _this._messages.has(voiceeventsid)) { // Do not emit an error here. Device is handling all signaling related errors. _this._messages.delete(voiceeventsid); _this._log.warn("Received an error while sending a message.", payload); _this._publisher.error('call-message', 'error', { code: error.code, message: error.message, voice_event_sid: voiceeventsid, }, _this); var twilioError = void 0; var errorConstructor = index.getPreciseSignalingErrorByCode(!!_this._options.enableImprovedSignalingErrorPrecision, error.code); if (typeof errorConstructor !== 'undefined') { twilioError = new errorConstructor(error); } if (!twilioError) { _this._log.error('Unknown Call Message Error: ', error); twilioError = new generated.GeneralErrors.UnknownError(error.message, error); } _this._log.debug('#error', error, twilioError); _this.emit('error', twilioError); } }; /** * Called when signaling is restored */ _this._onSignalingReconnected = function () { if (_this._signalingStatus !== Call.State.Reconnecting) { return; } _this._log.info('Signaling Connection reestablished.'); _this._signalingStatus = Call.State.Open; if (_this._mediaStatus === Call.State.Open) { _this._publisher.info('connection', 'reconnected', null, _this); _this._log.debug('#reconnected'); _this.emit('reconnected'); _this._status = Call.State.Open; } }; /** * Called when we receive a transportClose event from pstream. * Re-emits the event. */ _this._onTransportClose = function () { _this._log.error('Received transportClose from pstream'); _this._log.debug('#transportClose'); _this.emit('transportClose'); if (_this._signalingReconnectToken) { _this._status = Call.State.Reconnecting; _this._signalingStatus = Call.State.Reconnecting; _this._publisher.info('connection', 'reconnecting', null, _this); _this._log.debug('#reconnecting'); _this.emit('reconnecting', new generated.SignalingErrors.ConnectionDisconnected()); } else { _this._status = Call.State.Closed; _this._signalingStatus = Call.State.Closed; } }; /** * Re-emit an StatsMonitor warning as a {@link Call}.warning or .warning-cleared event. * @param warningData * @param wasCleared - Whether this is a -cleared or -raised event. */ _this._reemitWarning = function (warningData, wasCleared) { var groupPrefix = /^audio/.test(warningData.name) ? 'audio-level-' : 'network-quality-'; var warningPrefix = WARNING_PREFIXES[warningData.threshold.name]; /** * NOTE: There are two "packet-loss" warnings: `high-packet-loss` and * `high-packets-lost-fraction`, so in this case we need to use a different * `WARNING_NAME` mapping. */ var warningName; if (warningData.name in MULTIPLE_THRESHOLD_WARNING_NAMES) { warningName = MULTIPLE_THRESHOLD_WARNING_NAMES[warningData.name][warningData.threshold.name]; } else if (warningData.name in WARNING_NAMES) { warningName = WARNING_NAMES[warningData.name]; } var warning = warningPrefix + warningName; _this._emitWarning(groupPrefix, warning, warningData.threshold.value, warningData.values || warningData.value, wasCleared, warningData); }; /** * Re-emit an StatsMonitor warning-cleared as a .warning-cleared event. * @param warningData */ _this._reemitWarningCleared = function (warningData) { _this._reemitWarning(warningData, true); }; _this._soundcache = config.soundcache; if (typeof config.onIgnore === 'function') { _this._onIgnore = config.onIgnore; } var message = options && options.twimlParams || {}; _this.customParameters = new Map(Object.entries(message).map(function (_a) { var key = _a[0], val = _a[1]; return [key, String(val)]; })); Object.assign(_this._options, options); if (_this._options.callParameters) { _this.parameters = _this._options.callParameters; } if (_this._options.reconnectToken) { _this._signalingReconnectToken = _this._options.reconnectToken; } _this._voiceEventSidGenerator = _this._options.voiceEventSidGenerator || sid.generateVoiceEventSid; _this._direction = _this.parameters.CallSid && !_this._options.reconnectCallSid ? Call.CallDirection.Incoming : Call.CallDirection.Outgoing; if (_this.parameters) { _this.callerInfo = _this.parameters.StirStatus ? { isVerified: _this.parameters.StirStatus === 'TN-Validation-Passed-A' } : null; } else { _this.callerInfo = null; } _this._mediaReconnectBackoff = new backoff.default(BACKOFF_CONFIG); _this._mediaReconnectBackoff.on('ready', function () { return _this._mediaHandler.iceRestart(); }); // temporary call sid to be used for outgoing calls _this.outboundConnectionId = generateTempCallSid(); var publisher = _this._publisher = config.publisher; if (_this._direction === Call.CallDirection.Incoming) { publisher.info('connection', 'incoming', null, _this); } else { publisher.info('connection', 'outgoing', { preflight: _this._options.preflight, reconnect: !!_this._options.reconnectCallSid, }, _this); } var monitor = _this._monitor = new (_this._options.StatsMonitor || statsMonitor.default)(); monitor.on('sample', _this._onRTCSample); // First 20 seconds or so are choppy, so let's not bother with these warnings. monitor.disableWarnings(); setTimeout(function () { return monitor.enableWarnings(); }, METRICS_DELAY); monitor.on('warning', function (data, wasCleared) { if (data.name === 'bytesSent' || data.name === 'bytesReceived') { _this._onMediaFailure(Call.MediaFailure.LowBytes); } _this._reemitWarning(data, wasCleared); }); monitor.on('warning-cleared', function (data) { _this._reemitWarningCleared(data); }); _this._mediaHandler = new (_this._options.MediaHandler)(config.audioHelper, config.pstream, { MediaStream: _this._options.MediaStream, RTCPeerConnection: _this._options.RTCPeerConnection, codecPreferences: _this._options.codecPreferences, dscp: _this._options.dscp, forceAggressiveIceNomination: _this._options.forceAggressiveIceNomination, maxAverageBitrate: _this._options.maxAverageBitrate, }); _this.on('volume', function (inputVolume, outputVolume) { _this._inputVolumeStreak = _this._checkVolume(inputVolume, _this._inputVolumeStreak, _this._latestInputVolume, 'input'); _this._outputVolumeStreak = _this._checkVolume(outputVolume, _this._outputVolumeStreak, _this._latestOutputVolume, 'output'); _this._latestInputVolume = inputVolume; _this._latestOutputVolume = outputVolume; }); _this._mediaHandler.onaudio = function (remoteAudio) { _this._log.debug('#audio'); _this.emit('audio', remoteAudio); }; _this._mediaHandler.onvolume = function (inputVolume, outputVolume, internalInputVolume, internalOutputVolume) { // (rrowland) These values mock the 0 -> 32767 format used by legacy getStats. We should look into // migrating to a newer standard, either 0.0 -> linear or -127 to 0 in dB, matching the range // chosen below. monitor.addVolumes((internalInputVolume / 255) * 32767, (internalOutputVolume / 255) * 32767); // (rrowland) 0.0 -> 1.0 linear _this.emit('volume', inputVolume, outputVolume); }; _this._mediaHandler.ondtlstransportstatechange = function (state) { var level = state === 'failed' ? 'error' : 'debug'; _this._publisher.post(level, 'dtls-transport-state', state, null, _this); }; _this._mediaHandler.onpcconnectionstatechange = function (state) { var level = 'debug'; var dtlsTransport = _this._mediaHandler.getRTCDtlsTransport(); if (state === 'failed') { level = dtlsTransport && dtlsTransport.state === 'failed' ? 'error' : 'warning'; } _this._publisher.post(level, 'pc-connection-state', state, null, _this); }; _this._mediaHandler.onicecandidate = function (candidate) { var payload = new icecandidate.IceCandidate(candidate).toPayload(); _this._publisher.debug('ice-candidate', 'ice-candidate', payload, _this); }; _this._mediaHandler.onselectedcandidatepairchange = function (pair) { var localCandidatePayload = new icecandidate.IceCandidate(pair.local).toPayload(); var remoteCandidatePayload = new icecandidate.IceCandidate(pair.remote, true).toPayload(); _this._publisher.debug('ice-candidate', 'selected-ice-candidate-pair', { local_candidate: localCandidatePayload, remote_candidate: remoteCandidatePayload, }, _this); }; _this._mediaHandler.oniceconnectionstatechange = function (state) { var level = state === 'failed' ? 'error' : 'debug'; _this._publisher.post(level, 'ice-connection-state', state, null, _this); }; _this._mediaHandler.onicegatheringfailure = function (type) { _this._publisher.warn('ice-gathering-state', type, null, _this); _this._onMediaFailure(Call.MediaFailure.IceGatheringFailed); }; _this._mediaHandler.onicegatheringstatechange = function (state) { _this._publisher.debug('ice-gathering-state', state, null, _this); }; _this._mediaHandler.onsignalingstatechange = function (state) { _this._publisher.debug('signaling-state', state, null, _this); }; _this._mediaHandler.ondisconnected = function (msg) { _this._log.warn(msg); _this._publisher.warn('network-quality-warning-raised', 'ice-connectivity-lost', { message: msg, }, _this); _this._log.debug('#warning', 'ice-connectivity-lost'); _this.emit('warning', 'ice-connectivity-lost'); _this._onMediaFailure(Call.MediaFailure.ConnectionDisconnected); }; _this._mediaHandler.onfailed = function (msg) { _this._onMediaFailure(Call.MediaFailure.ConnectionFailed); }; _this._mediaHandler.onconnected = function () { // First time _mediaHandler is connected, but ICE Gathering issued an ICE restart and succeeded. if (_this._status === Call.State.Reconnecting) { _this._onMediaReconnected(); } }; _this._mediaHandler.onreconnected = function (msg) { _this._log.info(msg); _this._publisher.info('network-quality-warning-cleared', 'ice-connectivity-lost', { message: msg, }, _this); _this._log.debug('#warning-cleared', 'ice-connectivity-lost'); _this.emit('warning-cleared', 'ice-connectivity-lost'); _this._onMediaReconnected(); }; _this._mediaHandler.onerror = function (e) { if (e.disconnect === true) { _this._disconnect(e.info && e.info.message); } var error = e.info.twilioError || new generated.GeneralErrors.UnknownError(e.info.message); _this._log.error('Received an error from MediaStream:', e); _this._log.debug('#error', error); _this.emit('error', error); }; _this._mediaHandler.onopen = function () { // NOTE(mroberts): While this may have been happening in previous // versions of Chrome, since Chrome 45 we have seen the // PeerConnection's onsignalingstatechange handler invoked multiple // times in the same signalingState 'stable'. When this happens, we // invoke this onopen function. If we invoke it twice without checking // for _status 'open', we'd accidentally close the PeerConnection. // // See <https://code.google.com/p/webrtc/issues/detail?id=4996>. if (_this._status === Call.State.Open || _this._status === Call.State.Reconnecting) { return; } else if (_this._status === Call.State.Ringing || _this._status === Call.State.Connecting) { _this.mute(_this._mediaHandler.isMuted); _this._mediaStatus = Call.State.Open; _this._maybeTransitionToOpen(); } else { // call was probably canceled sometime before this _this._mediaHandler.close(); } }; _this._mediaHandler.onclose = function () { _this._status = Call.State.Closed; if (_this._options.shouldPlayDisconnect && _this._options.shouldPlayDisconnect() // Don't play disconnect sound if this was from a cancel event. i.e. the call // was ignored or hung up even before it was answered. // Similarly, don't play disconnect sound if the call was rejected. && !_this._isCancelled && !_this._isRejected) { _this._soundcache.get(device.default.SoundName.Disconnect).play(); } monitor.disable(); _this._publishMetrics(); if (!_this._isCancelled && !_this._isRejected) { // tslint:disable no-console _this._log.debug('#disconnect'); _this.emit('disconnect', _this); } }; _this._pstream = config.pstream; _this._pstream.on('ack', _this._onAck); _this._pstream.on('cancel', _this._onCancel); _this._pstream.on('error', _this._onSignalingError); _this._pstream.on('ringing', _this._onRinging); _this._pstream.on('transportClose', _this._onTransportClose); _this._pstream.on('connected', _this._onConnected); _this._pstream.on('message', _this._onMessageReceived); _this.on('error', function (error) { _this._publisher.error('connection', 'error', { code: error.code, message: error.message, }, _this); if (_this._pstream && _this._pstream.status === 'disconnected') { _this._cleanupEventListeners(); } }); _this.on('disconnect', function () { _this._cleanupEventListeners(); }); return _this; } Object.defineProperty(Call.prototype, "direction", { /** * Whether this {@link Call} is incoming or outgoing. */ get: function () { return this._direction; }, enumerable: false, configurable: true }); Object.defineProperty(Call.prototype, "codec", { /** * Audio codec used for this {@link Call}. Expecting {@link Call.Codec} but * will copy whatever we get from RTC stats. */ get: function () { return this._codec; }, enumerable: false, configurable: true }); Object.defineProperty(Call.prototype, "connectToken", { /** * The connect token is available as soon as the call is established * and connected to Twilio. Use this token to reconnect to a call via the {@link Device.connect} * method. * * For incoming calls, it is available in the call object after the {@link Device.incomingEvent} is emitted. * For outgoing calls, it is available after the {@link Call.acceptEvent} is emitted. */ get: function () { var _this = this; var signalingReconnectToken = this._signalingReconnectToken; var callSid = this.parameters && this.parameters.CallSid ? this.parameters.CallSid : undefined; if (!signalingReconnectToken || !callSid) { return; } var customParameters = this.customParameters && typeof this.customParameters.keys === 'function' ? Array.from(this.customParameters.keys()).reduce(function (result, key) { result[key] = _this.customParameters.get(key); return result; }, {}) : {}; var parameters = this.parameters || {}; return btoa(encodeURIComponent(JSON.stringify({ customParameters: customParameters, parameters: parameters, signalingReconnectToken: signalingReconnectToken, }))); }, enumerable: false, configurable: true }); /** * Set the audio input tracks from a given stream. * @internal * @param stream */ Call.prototype._setInputTracksFromStream = function (stream) { return this._mediaHandler.setInputTracksFromStream(stream); }; /** * Set the audio output sink IDs. * @internal * @param sinkIds */ Call.prototype._setSinkIds = function (sinkIds) { return this._mediaHandler._setSinkIds(sinkIds); }; /** * Accept the incoming {@link Call}. * @param [options] */ Call.prototype.accept = function (options) { var _this = this; this._log.debug('.accept', options); if (this._status !== Call.State.Pending) { this._log.debug(".accept noop. status is '".concat(this._status, "'")); return; } options = options || {}; var rtcConfiguration = options.rtcConfiguration || this._options.rtcConfiguration; var rtcConstraints = options.rtcConstraints || this._options.rtcConstraints || {}; var audioConstraints = { audio: typeof rtcConstraints.audio !== 'undefined' ? rtcConstraints.audio : true, }; this._status = Call.State.Connecting; var connect = function () { if (_this._status !== Call.State.Connecting) { // call must have been canceled _this._cleanupEventListeners(); _this._mediaHandler.close(); return; } var onAnswer = function (pc) { // Report that the call was answered, and directionality var eventName = _this._direction === Call.CallDirection.Incoming ? 'accepted-by-local' : 'accepted-by-remote'; _this._publisher.info('connection', eventName, null, _this); // Report the preferred codec and params as they appear in the SDP var _a = sdp.getPreferredCodecInfo(_this._mediaHandler.version.getSDP()), codecName = _a.codecName, codecParams = _a.codecParams; _this._publisher.info('settings', 'codec', { codec_params: codecParams, selected_codec: codecName, }, _this); // Enable RTC monitoring _this._monitor.enable(pc); }; var sinkIds = typeof _this._options.getSinkIds === 'function' && _this._options.getSinkIds(); if (Array.isArray(sinkIds)) { _this._mediaHandler._setSinkIds(sinkIds).catch(function () { // (rrowland) We don't want this to throw to console since the customer // can't control this. This will most commonly be rejected on browsers // that don't support setting sink IDs. }); } _this._pstream.addListener('hangup', _this._onHangup); if (_this._direction === Call.CallDirection.Incoming) { _this._isAnswered = true; _this._pstream.on('answer', _this._onAnswer); _this._mediaHandler.answerIncomingCall(_this.parameters.CallSid, _this._options.offerSdp, rtcConfiguration, onAnswer); } else { var params = Array.from(_this.customParameters.entries()).map(function (pair) { return "".concat(encodeURIComponent(pair[0]), "=").concat(encodeURIComponent(pair[1])); }).join('&'); _this._pstream.on('answer', _this._onAnswer); _this._mediaHandler.makeOutgoingCall(params, _this._signalingReconnectToken, _this._options.reconnectCallSid || _this.outboundConnectionId, rtcConfiguration, onAnswer); } }; if (this._options.beforeAccept) { this._options.beforeAccept(this); } var inputStream = typeof this._options.getInputStream === 'function' && this._options.getInputStream(); var promise = inputStream ? this._mediaHandler.setInputTracksFromStream(inputStream) : this._mediaHandler.openDefaultDeviceWithConstraints(audioConstraints); promise.then(function () { _this._publisher.info('get-user-media', 'succeeded', { data: { audioConstraints: audioConstraints }, }, _this); connect(); }, function (error) { var twilioError; if (error.code === 31208 || ['PermissionDeniedError', 'NotAllowedError'].indexOf(error.name) !== -1) { twilioError = new generated.UserMediaErrors.PermissionDeniedError(); _this._publisher.error('get-user-media', 'denied', { data: { audioConstraints: audioConstraints, error: error, }, }, _this); } else { twilioError = new generated.UserMediaErrors.AcquisitionFailedError(); _this._publisher.error('get-user-media', 'failed', { data: { audioConstraints: audioConstraints, error: error, }, }, _this); } _this._disconnect(); _this._log.debug('#error', error); _this.emit('error', twilioError); }); }; /** * Disconnect from the {@link Call}. */ Call.prototype.disconnect = function () { this._log.debug('.disconnect'); this._disconnect(); }; /** * Get the local MediaStream, if set. */ Call.prototype.getLocalStream = function () { return this._mediaHandler && this._mediaHandler.stream; }; /** * Get the remote MediaStream, if set. */ Call.prototype.getRemoteStream = function () { return this._mediaHandler && this._mediaHandler._remoteStream; }; /** * Ignore the incoming {@link Call}. */ Call.prototype.ignore = function () { this._log.debug('.ignore'); if (this._status !== Call.State.Pending) { this._log.debug(".ignore noop. status is '".concat(this._status, "'")); return; } this._status = Call.State.Closed; this._mediaHandler.ignore(this.parameters.CallSid); this._publisher.info('connection', 'ignored-by-local', null, this); if (this._onIgnore) { this._onIgnore(); } }; /** * Check whether call is muted */ Call.prototype.isMuted = function () { return this._mediaHandler.isMuted; }; /** * Mute incoming audio. * @param shouldMute - Whether the incoming audio should be muted. Defaults to true. */ Call.prototype.mute = function (shouldMute) { if (shouldMute === void 0) { shouldMute = true; } this._log.debug('.mute', shouldMute); var wasMuted = this._mediaHandler.isMuted; this._mediaHandler.mute(shouldMute); var isMuted = this._mediaHandler.isMuted; if (wasMuted !== isMuted) { this._publisher.info('connection', isMuted ? 'muted' : 'unmuted', null, this); this._log.debug('#mute', isMuted); this.emit('mute', isMuted, this); } }; /** * Post an event to Endpoint Analytics indicating that the end user * has given call quality feedback. Called without a score, this * will report that the customer declined to give feedback. * @param score - The end-user's rating of the call; an * integer 1 through 5. Or undefined if the user declined to give * feedback. * @param issue - The primary issue the end user * experienced on the call. Can be: ['one-way-audio', 'choppy-audio', * 'dropped-call', 'audio-latency', 'noisy-call', 'echo'] */ Call.prototype.postFeedback = function (score, issue) { if (typeof score === 'undefined' || score === null) { return this._postFeedbackDeclined(); } if (!Object.values(Call.FeedbackScore).includes(score)) { throw new index.InvalidArgumentError("Feedback score must be one of: ".concat(Object.values(Call.FeedbackScore))); } if (typeof issue !== 'undefined' && issue !== null && !Object.values(Call.FeedbackIssue).includes(issue)) { throw new index.InvalidArgumentError("Feedback issue must be one of: ".concat(Object.values(Call.FeedbackIssue))); } return this._publisher.info('feedback', 'received', { issue_name: issue, quality_score: score, }, this, true); }; /** * Reject the incoming {@link Call}. */ Call.prototype.reject = function () { this._log.debug('.reject'); if (this._status !== Call.State.Pending) { this._log.debug(".reject noop. status is '".concat(this._status, "'")); return; } this._isRejected = true; this._pstream.reject(this.parameters.CallSid); this._mediaHandler.reject(this.parameters.CallSid); this._publisher.info('connection', 'rejected-by-local', null, this); this._cleanupEventListeners(); this._mediaHandler.close(); this._status = Call.State.Closed; this._log.debug('#reject'); this.emit('reject'); }; /** * Send a string of digits. * @param digits */ Call.prototype.sendDigits = function (digits) { var _this = this; this._log.debug('.sendDigits', digits); if (digits.match(/[^0-9*#w]/)) { throw new index.InvalidArgumentError('Illegal character passed into sendDigits'); } var customSounds = this._options.customSounds || {}; var sequence = []; digits.split('').forEach(function (digit) { var dtmf = (digit !== 'w') ? "dtmf".concat(digit) : ''; if (dtmf === 'dtmf*') { dtmf = 'dtmfs'; } if (dtmf === 'dtmf#') { dtmf = 'dtmfh'; } sequence.push(dtmf); }); var playNextDigit = function () { var digit = sequence.shift(); if (digit) { if (_this._options.dialtonePlayer && !customSounds[digit]) { _this._options.dialtonePlayer.play(digit); } else { _this._soundcache.get(digit).play(); } } if (sequence.length) { setTimeout(function () { return playNextDigit(); }, 200); } }; playNextDigit(); var dtmfSender = this._mediaHandler.getOrCreateDTMFSender(); function insertDTMF(dtmfs) { if (!dtmfs.length) { return; } var dtmf = dtmfs.shift(); if (dtmf && dtmf.length) { dtmfSender.insertDTMF(dtmf, DTMF_TONE_DURATION, DTMF_INTER_TONE_GAP); } setTimeout(insertDTMF.bind(null, dtmfs), DTMF_PAUSE_DURATION); } if (dtmfSender) { if (!('canInsertDTMF' in dtmfSender) || dtmfSender.canInsertDTMF) { this._log.info('Sending digits using RTCDTMFSender'); // NOTE(mroberts): We can't just map 'w' to ',' since // RTCDTMFSender's pause duration is 2 s and Twilio's is more // like 500 ms. Instead, we will fudge it with setTimeout. insertDTMF(digits.split('w')); return; } this._log.info('RTCDTMFSender cannot insert DTMF'); } // send pstream message to send DTMF this._log.info('Sending digits over PStream'); if (this._pstream !== null && this._pstream.status !== 'disconnected') { this._pstream.dtmf(this.parameters.CallSid, digits); } else { var error = new generated.GeneralErrors.ConnectionError('Could not send DTMF: Signaling channel is disconnected'); this._log.debug('#