UNPKG

jssip

Version:

The Javascript SIP library

1,296 lines 101 kB
"use strict"; /* globals RTCPeerConnection: false, RTCSessionDescription: false */ /* eslint-disable no-invalid-this */ const EventEmitter = require('events').EventEmitter; const sdp_transform = require('sdp-transform'); const Logger = require('./Logger'); const JsSIP_C = require('./Constants'); const Exceptions = require('./Exceptions'); const Transactions = require('./Transactions'); const Utils = require('./Utils'); const Timers = require('./Timers'); const SIPMessage = require('./SIPMessage'); const Dialog = require('./Dialog'); const RequestSender = require('./RequestSender'); const RTCSession_DTMF = require('./RTCSession/DTMF'); const RTCSession_Info = require('./RTCSession/Info'); const RTCSession_ReferNotifier = require('./RTCSession/ReferNotifier'); const RTCSession_ReferSubscriber = require('./RTCSession/ReferSubscriber'); const URI = require('./URI'); const logger = new Logger('RTCSession'); const C = { // RTCSession states. STATUS_NULL: 0, STATUS_INVITE_SENT: 1, STATUS_1XX_RECEIVED: 2, STATUS_INVITE_RECEIVED: 3, STATUS_WAITING_FOR_ANSWER: 4, STATUS_ANSWERED: 5, STATUS_WAITING_FOR_ACK: 6, STATUS_CANCELED: 7, STATUS_TERMINATED: 8, STATUS_CONFIRMED: 9, }; /** * Local variables. */ const holdMediaTypes = ['audio', 'video']; module.exports = class RTCSession extends EventEmitter { /** * Expose C object. */ static get C() { return C; } constructor(ua) { logger.debug('new'); super(); this._id = null; this._ua = ua; this._status = C.STATUS_NULL; this._dialog = null; this._earlyDialogs = {}; this._contact = null; this._from_tag = null; this._to_tag = null; // The RTCPeerConnection instance (public attribute). this._connection = null; // Prevent races on serial PeerConnction operations. this._connectionPromiseQueue = Promise.resolve(); // Incoming/Outgoing request being currently processed. this._request = null; // Cancel state for initial outgoing request. this._is_canceled = false; this._cancel_reason = ''; // RTCSession confirmation flag. this._is_confirmed = false; // Is late SDP being negotiated. this._late_sdp = false; // Default rtcOfferConstraints and rtcAnswerConstrainsts (passed in connect() or answer()). this._rtcOfferConstraints = null; this._rtcAnswerConstraints = null; // Local MediaStream. this._localMediaStream = null; this._localMediaStreamLocallyGenerated = false; // Flag to indicate PeerConnection ready for new actions. this._rtcReady = true; // Flag to indicate ICE candidate gathering is finished even if iceGatheringState is not yet 'complete'. this._iceReady = false; // SIP Timers. this._timers = { ackTimer: null, expiresTimer: null, invite2xxTimer: null, userNoAnswerTimer: null, }; // Session info. this._direction = null; this._local_identity = null; this._remote_identity = null; this._start_time = null; this._end_time = null; this._tones = null; // Mute/Hold state. this._audioMuted = false; this._videoMuted = false; this._localHold = false; this._remoteHold = false; // Session Timers (RFC 4028). this._sessionTimers = { enabled: this._ua.configuration.session_timers, refreshMethod: this._ua.configuration.session_timers_refresh_method, defaultExpires: JsSIP_C.SESSION_EXPIRES, currentExpires: null, running: false, refresher: false, timer: null, // A setTimeout. }; // Map of ReferSubscriber instances indexed by the REFER's CSeq number. this._referSubscribers = {}; // Custom session empty object for high level use. this._data = {}; } /** * User API */ // Expose RTCSession constants as a property of the RTCSession instance. get C() { return C; } // Expose session failed/ended causes as a property of the RTCSession instance. get causes() { return JsSIP_C.causes; } get id() { return this._id; } get connection() { return this._connection; } get contact() { return this._contact; } get direction() { return this._direction; } get local_identity() { return this._local_identity; } get remote_identity() { return this._remote_identity; } get start_time() { return this._start_time; } get end_time() { return this._end_time; } get data() { return this._data; } set data(_data) { this._data = _data; } get status() { return this._status; } isInProgress() { switch (this._status) { case C.STATUS_NULL: case C.STATUS_INVITE_SENT: case C.STATUS_1XX_RECEIVED: case C.STATUS_INVITE_RECEIVED: case C.STATUS_WAITING_FOR_ANSWER: { return true; } default: { return false; } } } isEstablished() { switch (this._status) { case C.STATUS_ANSWERED: case C.STATUS_WAITING_FOR_ACK: case C.STATUS_CONFIRMED: { return true; } default: { return false; } } } isEnded() { switch (this._status) { case C.STATUS_CANCELED: case C.STATUS_TERMINATED: { return true; } default: { return false; } } } isMuted() { return { audio: this._audioMuted, video: this._videoMuted, }; } isOnHold() { return { local: this._localHold, remote: this._remoteHold, }; } connect(target, options = {}, initCallback) { logger.debug('connect()'); const originalTarget = target; const eventHandlers = Utils.cloneObject(options.eventHandlers); const extraHeaders = Utils.cloneArray(options.extraHeaders); const mediaConstraints = Utils.cloneObject(options.mediaConstraints, { audio: true, video: true, }); const mediaStream = options.mediaStream || null; const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); const rtcConstraints = options.rtcConstraints || null; const rtcOfferConstraints = options.rtcOfferConstraints || null; this._rtcOfferConstraints = rtcOfferConstraints; this._rtcAnswerConstraints = options.rtcAnswerConstraints || null; this._data = options.data || this._data; // Check target. if (target === undefined) { throw new TypeError('Not enough arguments'); } // Check Session Status. if (this._status !== C.STATUS_NULL) { throw new Exceptions.InvalidStateError(this._status); } // Check WebRTC support. // eslint-disable-next-line no-undef if (!window.RTCPeerConnection) { throw new Exceptions.NotSupportedError('WebRTC not supported'); } // Check target validity. target = this._ua.normalizeTarget(target); if (!target) { throw new TypeError(`Invalid target: ${originalTarget}`); } // Session Timers. if (this._sessionTimers.enabled) { if (Utils.isDecimal(options.sessionTimersExpires)) { if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) { this._sessionTimers.defaultExpires = options.sessionTimersExpires; } else { this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; } } } // Set event handlers. for (const event in eventHandlers) { if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) { this.on(event, eventHandlers[event]); } } // Session parameter initialization. this._from_tag = Utils.newTag(); // Set anonymous property. const anonymous = options.anonymous || false; const requestParams = { from_tag: this._from_tag }; this._contact = this._ua.contact.toString({ anonymous, outbound: true, }); if (anonymous) { requestParams.from_display_name = 'Anonymous'; requestParams.from_uri = new URI('sip', 'anonymous', 'anonymous.invalid'); extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`); extraHeaders.push('Privacy: id'); } else if (options.fromUserName) { requestParams.from_uri = new URI('sip', options.fromUserName, this._ua.configuration.uri.host); extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`); } if (options.fromDisplayName) { requestParams.from_display_name = options.fromDisplayName; } extraHeaders.push(`Contact: ${this._contact}`); extraHeaders.push('Content-Type: application/sdp'); if (this._sessionTimers.enabled) { extraHeaders.push(`Session-Expires: ${this._sessionTimers.defaultExpires}${this._ua.configuration.session_timers_force_refresher ? ';refresher=uac' : ''}`); } this._request = new SIPMessage.InitialOutgoingInviteRequest(target, this._ua, requestParams, extraHeaders); this._id = this._request.call_id + this._from_tag; // Create a new RTCPeerConnection instance. this._createRTCConnection(pcConfig, rtcConstraints); // Set internal properties. this._direction = 'outgoing'; this._local_identity = this._request.from; this._remote_identity = this._request.to; // User explicitly provided a newRTCSession callback for this session. if (initCallback) { initCallback(this); } this._newRTCSession('local', this._request); this._sendInitialRequest(mediaConstraints, rtcOfferConstraints, mediaStream); } init_incoming(request, initCallback) { logger.debug('init_incoming()'); let expires; const contentType = request.hasHeader('Content-Type') ? request.getHeader('Content-Type').toLowerCase() : undefined; // Check body and content type. if (request.body && contentType !== 'application/sdp') { request.reply(415); return; } // Session parameter initialization. this._status = C.STATUS_INVITE_RECEIVED; this._from_tag = request.from_tag; this._id = request.call_id + this._from_tag; this._request = request; this._contact = this._ua.contact.toString(); // Get the Expires header value if exists. if (request.hasHeader('expires')) { expires = request.getHeader('expires') * 1000; } /* Set the to_tag before * replying a response code that will create a dialog. */ request.to_tag = Utils.newTag(); // An error on dialog creation will fire 'failed' event. if (!this._createDialog(request, 'UAS', true)) { request.reply(500, 'Missing Contact header field'); return; } if (request.body) { this._late_sdp = false; } else { this._late_sdp = true; } this._status = C.STATUS_WAITING_FOR_ANSWER; // Set userNoAnswerTimer. this._timers.userNoAnswerTimer = setTimeout(() => { request.reply(408); this._failed('local', null, JsSIP_C.causes.NO_ANSWER); }, this._ua.configuration.no_answer_timeout); /* Set expiresTimer * RFC3261 13.3.1 */ if (expires) { this._timers.expiresTimer = setTimeout(() => { if (this._status === C.STATUS_WAITING_FOR_ANSWER) { request.reply(487); this._failed('system', null, JsSIP_C.causes.EXPIRES); } }, expires); } // Set internal properties. this._direction = 'incoming'; this._local_identity = request.to; this._remote_identity = request.from; // A init callback was specifically defined. if (initCallback) { initCallback(this); } // Fire 'newRTCSession' event. this._newRTCSession('remote', request); // The user may have rejected the call in the 'newRTCSession' event. if (this._status === C.STATUS_TERMINATED) { return; } // Reply 180. request.reply(180, null, [`Contact: ${this._contact}`]); // Fire 'progress' event. // TODO: Document that 'response' field in 'progress' event is null for incoming calls. this._progress('local', null); } /** * Answer the call. */ answer(options = {}) { logger.debug('answer()'); const request = this._request; const extraHeaders = Utils.cloneArray(options.extraHeaders); const mediaConstraints = Utils.cloneObject(options.mediaConstraints); const mediaStream = options.mediaStream || null; const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); const rtcConstraints = options.rtcConstraints || null; const rtcAnswerConstraints = options.rtcAnswerConstraints || null; const rtcOfferConstraints = Utils.cloneObject(options.rtcOfferConstraints); let tracks; let peerHasAudioLine = false; let peerHasVideoLine = false; let peerOffersFullAudio = false; let peerOffersFullVideo = false; this._rtcAnswerConstraints = rtcAnswerConstraints; this._rtcOfferConstraints = options.rtcOfferConstraints || null; this._data = options.data || this._data; // Check Session Direction and Status. if (this._direction !== 'incoming') { throw new Exceptions.NotSupportedError('"answer" not supported for outgoing RTCSession'); } // Check Session status. if (this._status !== C.STATUS_WAITING_FOR_ANSWER) { throw new Exceptions.InvalidStateError(this._status); } // Session Timers. if (this._sessionTimers.enabled) { if (Utils.isDecimal(options.sessionTimersExpires)) { if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) { this._sessionTimers.defaultExpires = options.sessionTimersExpires; } else { this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; } } } this._status = C.STATUS_ANSWERED; // An error on dialog creation will fire 'failed' event. if (!this._createDialog(request, 'UAS')) { request.reply(500, 'Error creating dialog'); return; } clearTimeout(this._timers.userNoAnswerTimer); extraHeaders.unshift(`Contact: ${this._contact}`); // Determine incoming media from incoming SDP offer (if any). const sdp = request.parseSDP(); // Make sure sdp.media is an array, not the case if there is only one media. if (!Array.isArray(sdp.media)) { sdp.media = [sdp.media]; } // Go through all medias in SDP to find offered capabilities to answer with. for (const m of sdp.media) { if (m.type === 'audio') { peerHasAudioLine = true; if (!m.direction || m.direction === 'sendrecv') { peerOffersFullAudio = true; } } if (m.type === 'video') { peerHasVideoLine = true; if (!m.direction || m.direction === 'sendrecv') { peerOffersFullVideo = true; } } } // Remove audio from mediaStream if suggested by mediaConstraints. if (mediaStream && mediaConstraints.audio === false) { tracks = mediaStream.getAudioTracks(); for (const track of tracks) { mediaStream.removeTrack(track); } } // Remove video from mediaStream if suggested by mediaConstraints. if (mediaStream && mediaConstraints.video === false) { tracks = mediaStream.getVideoTracks(); for (const track of tracks) { mediaStream.removeTrack(track); } } // Set audio constraints based on incoming stream if not supplied. if (!mediaStream && mediaConstraints.audio === undefined) { mediaConstraints.audio = peerOffersFullAudio; } // Set video constraints based on incoming stream if not supplied. if (!mediaStream && mediaConstraints.video === undefined) { mediaConstraints.video = peerOffersFullVideo; } // Don't ask for audio if the incoming offer has no audio section. if (!mediaStream && !peerHasAudioLine && !rtcOfferConstraints.offerToReceiveAudio) { mediaConstraints.audio = false; } // Don't ask for video if the incoming offer has no video section. if (!mediaStream && !peerHasVideoLine && !rtcOfferConstraints.offerToReceiveVideo) { mediaConstraints.video = false; } // Create a new RTCPeerConnection instance. // TODO: This may throw an error, should react. this._createRTCConnection(pcConfig, rtcConstraints); Promise.resolve() // Handle local MediaStream. .then(() => { // A local MediaStream is given, use it. if (mediaStream) { return mediaStream; } // Audio and/or video requested, prompt getUserMedia. else if (mediaConstraints.audio || mediaConstraints.video) { this._localMediaStreamLocallyGenerated = true; return navigator.mediaDevices .getUserMedia(mediaConstraints) .catch(error => { if (this._status === C.STATUS_TERMINATED) { throw new Error('terminated'); } request.reply(480); this._failed('local', null, JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS); logger.warn('emit "getusermediafailed" [error:%o]', error); this.emit('getusermediafailed', error); throw new Error('getUserMedia() failed'); }); } }) // Attach MediaStream to RTCPeerconnection. .then(stream => { if (this._status === C.STATUS_TERMINATED) { throw new Error('terminated'); } this._localMediaStream = stream; if (stream) { stream.getTracks().forEach(track => { this._connection.addTrack(track, stream); }); } }) // Set remote description. .then(() => { if (this._late_sdp) { return; } const e = { originator: 'remote', type: 'offer', sdp: request.body }; logger.debug('emit "sdp"'); this.emit('sdp', e); const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp }); this._connectionPromiseQueue = this._connectionPromiseQueue .then(() => this._connection.setRemoteDescription(offer)) .catch(error => { request.reply(488); this._failed('system', null, JsSIP_C.causes.WEBRTC_ERROR); logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); this.emit('peerconnection:setremotedescriptionfailed', error); throw new Error('peerconnection.setRemoteDescription() failed'); }); return this._connectionPromiseQueue; }) // Create local description. .then(() => { if (this._status === C.STATUS_TERMINATED) { throw new Error('terminated'); } // TODO: Is this event already useful? this._connecting(request); if (!this._late_sdp) { return this._createLocalDescription('answer', rtcAnswerConstraints).catch(() => { request.reply(500); throw new Error('_createLocalDescription() failed'); }); } else { return this._createLocalDescription('offer', this._rtcOfferConstraints).catch(() => { request.reply(500); throw new Error('_createLocalDescription() failed'); }); } }) // Send reply. .then(desc => { if (this._status === C.STATUS_TERMINATED) { throw new Error('terminated'); } this._handleSessionTimersInIncomingRequest(request, extraHeaders); request.reply(200, null, extraHeaders, desc, () => { this._status = C.STATUS_WAITING_FOR_ACK; this._setInvite2xxTimer(request, desc); this._setACKTimer(); this._accepted('local'); }, () => { this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); }); }) .catch(error => { if (this._status === C.STATUS_TERMINATED) { return; } logger.warn(`answer() failed: ${error.message}`); this._failed('system', error.message, JsSIP_C.causes.INTERNAL_ERROR); }); } /** * Terminate the call. */ terminate(options = {}) { logger.debug('terminate()'); const cause = options.cause || JsSIP_C.causes.BYE; const extraHeaders = Utils.cloneArray(options.extraHeaders); const body = options.body; let cancel_reason; let status_code = options.status_code; let reason_phrase = options.reason_phrase; // Check Session Status. if (this._status === C.STATUS_TERMINATED) { throw new Exceptions.InvalidStateError(this._status); } switch (this._status) { // - UAC - case C.STATUS_NULL: case C.STATUS_INVITE_SENT: case C.STATUS_1XX_RECEIVED: { logger.debug('canceling session'); if (status_code && (status_code < 200 || status_code >= 700)) { throw new TypeError(`Invalid status_code: ${status_code}`); } else if (status_code) { reason_phrase = reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; cancel_reason = `SIP ;cause=${status_code} ;text="${reason_phrase}"`; } // Check Session Status. if (this._status === C.STATUS_NULL || this._status === C.STATUS_INVITE_SENT) { this._is_canceled = true; this._cancel_reason = cancel_reason; } else if (this._status === C.STATUS_1XX_RECEIVED) { this._request.cancel(cancel_reason); } this._status = C.STATUS_CANCELED; this._failed('local', null, JsSIP_C.causes.CANCELED); break; } // - UAS - case C.STATUS_WAITING_FOR_ANSWER: case C.STATUS_ANSWERED: { logger.debug('rejecting session'); status_code = status_code || 480; if (status_code < 300 || status_code >= 700) { throw new TypeError(`Invalid status_code: ${status_code}`); } this._request.reply(status_code, reason_phrase, extraHeaders, body); this._failed('local', null, JsSIP_C.causes.REJECTED); break; } case C.STATUS_WAITING_FOR_ACK: case C.STATUS_CONFIRMED: { logger.debug('terminating session'); reason_phrase = options.reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; if (status_code && (status_code < 200 || status_code >= 700)) { throw new TypeError(`Invalid status_code: ${status_code}`); } else if (status_code) { extraHeaders.push(`Reason: SIP ;cause=${status_code}; text="${reason_phrase}"`); } /* RFC 3261 section 15 (Terminating a session): * * "...the callee's UA MUST NOT send a BYE on a confirmed dialog * until it has received an ACK for its 2xx response or until the server * transaction times out." */ if (this._status === C.STATUS_WAITING_FOR_ACK && this._direction === 'incoming' && this._request.server_transaction.state !== Transactions.C.STATUS_TERMINATED) { // Save the dialog for later restoration. const dialog = this._dialog; // Send the BYE as soon as the ACK is received... this.receiveRequest = ({ method }) => { if (method === JsSIP_C.ACK) { this.sendRequest(JsSIP_C.BYE, { extraHeaders, body, }); dialog.terminate(); } }; // .., or when the INVITE transaction times out this._request.server_transaction.on('stateChanged', () => { if (this._request.server_transaction.state === Transactions.C.STATUS_TERMINATED) { this.sendRequest(JsSIP_C.BYE, { extraHeaders, body, }); dialog.terminate(); } }); this._ended('local', null, cause); // Restore the dialog into 'this' in order to be able to send the in-dialog BYE :-). this._dialog = dialog; // Restore the dialog into 'ua' so the ACK can reach 'this' session. this._ua.newDialog(dialog); } else { this.sendRequest(JsSIP_C.BYE, { extraHeaders, body, }); this._ended('local', null, cause); } } } } sendDTMF(tones, options = {}) { logger.debug('sendDTMF() | tones: %s', tones); let duration = options.duration || null; let interToneGap = options.interToneGap || null; const transportType = options.transportType || JsSIP_C.DTMF_TRANSPORT.INFO; if (tones === undefined) { throw new TypeError('Not enough arguments'); } // Check Session Status. if (this._status !== C.STATUS_CONFIRMED && this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_1XX_RECEIVED) { throw new Exceptions.InvalidStateError(this._status); } // Check Transport type. if (transportType !== JsSIP_C.DTMF_TRANSPORT.INFO && transportType !== JsSIP_C.DTMF_TRANSPORT.RFC2833) { throw new TypeError(`invalid transportType: ${transportType}`); } // Convert to string. if (typeof tones === 'number') { tones = tones.toString(); } // Check tones. if (!tones || typeof tones !== 'string' || !tones.match(/^[0-9A-DR#*,]+$/i)) { throw new TypeError(`Invalid tones: ${tones}`); } // Check duration. if (duration && !Utils.isDecimal(duration)) { throw new TypeError(`Invalid tone duration: ${duration}`); } else if (!duration) { duration = RTCSession_DTMF.C.DEFAULT_DURATION; } else if (duration < RTCSession_DTMF.C.MIN_DURATION) { logger.debug(`"duration" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_DURATION} milliseconds`); duration = RTCSession_DTMF.C.MIN_DURATION; } else if (duration > RTCSession_DTMF.C.MAX_DURATION) { logger.debug(`"duration" value is greater than the maximum allowed, setting it to ${RTCSession_DTMF.C.MAX_DURATION} milliseconds`); duration = RTCSession_DTMF.C.MAX_DURATION; } else { duration = Math.abs(duration); } options.duration = duration; // Check interToneGap. if (interToneGap && !Utils.isDecimal(interToneGap)) { throw new TypeError(`Invalid interToneGap: ${interToneGap}`); } else if (!interToneGap) { interToneGap = RTCSession_DTMF.C.DEFAULT_INTER_TONE_GAP; } else if (interToneGap < RTCSession_DTMF.C.MIN_INTER_TONE_GAP) { logger.debug(`"interToneGap" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_INTER_TONE_GAP} milliseconds`); interToneGap = RTCSession_DTMF.C.MIN_INTER_TONE_GAP; } else { interToneGap = Math.abs(interToneGap); } // RFC2833. Let RTCDTMFSender enqueue the DTMFs. if (transportType === JsSIP_C.DTMF_TRANSPORT.RFC2833) { // Send DTMF in current audio RTP stream. const sender = this._getDTMFRTPSender(); if (sender) { // Add remaining buffered tones. tones = sender.toneBuffer + tones; // Insert tones. sender.insertDTMF(tones, duration, interToneGap); } return; } if (this._tones) { // Tones are already queued, just add to the queue. this._tones += tones; return; } this._tones = tones; // Send the first tone. _sendDTMF.call(this); function _sendDTMF() { let timeout; if (this._status === C.STATUS_TERMINATED || !this._tones) { // Stop sending DTMF. this._tones = null; return; } // Retrieve the next tone. const tone = this._tones[0]; // Remove the tone from this._tones. this._tones = this._tones.substring(1); if (tone === ',') { timeout = 2000; } else { // Send DTMF via SIP INFO messages. const dtmf = new RTCSession_DTMF(this); options.eventHandlers = { onFailed: () => { this._tones = null; }, }; dtmf.send(tone, options); timeout = duration + interToneGap; } // Set timeout for the next tone. setTimeout(_sendDTMF.bind(this), timeout); } } sendInfo(contentType, body, options = {}) { logger.debug('sendInfo()'); // Check Session Status. if (this._status !== C.STATUS_CONFIRMED && this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_1XX_RECEIVED) { throw new Exceptions.InvalidStateError(this._status); } const info = new RTCSession_Info(this); info.send(contentType, body, options); } /** * Mute */ mute(options = { audio: true, video: false }) { logger.debug('mute()'); let audioMuted = false, videoMuted = false; if (this._audioMuted === false && options.audio) { audioMuted = true; this._audioMuted = true; this._toggleMuteAudio(true); } if (this._videoMuted === false && options.video) { videoMuted = true; this._videoMuted = true; this._toggleMuteVideo(true); } if (audioMuted === true || videoMuted === true) { this._onmute({ audio: audioMuted, video: videoMuted, }); } } /** * Unmute */ unmute(options = { audio: true, video: true }) { logger.debug('unmute()'); let audioUnMuted = false, videoUnMuted = false; if (this._audioMuted === true && options.audio) { audioUnMuted = true; this._audioMuted = false; if (this._localHold === false) { this._toggleMuteAudio(false); } } if (this._videoMuted === true && options.video) { videoUnMuted = true; this._videoMuted = false; if (this._localHold === false) { this._toggleMuteVideo(false); } } if (audioUnMuted === true || videoUnMuted === true) { this._onunmute({ audio: audioUnMuted, video: videoUnMuted, }); } } /** * Hold */ hold(options = {}, done) { logger.debug('hold()'); if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) { return false; } if (this._localHold === true) { return false; } if (!this.isReadyToReOffer()) { return false; } this._localHold = true; this._onhold('local'); const eventHandlers = { succeeded: () => { if (done) { done(); } }, failed: () => { this.terminate({ cause: JsSIP_C.causes.WEBRTC_ERROR, status_code: 500, reason_phrase: 'Hold Failed', }); }, }; if (options.useUpdate) { this._sendUpdate({ sdpOffer: true, eventHandlers, extraHeaders: options.extraHeaders, }); } else { this._sendReinvite({ eventHandlers, extraHeaders: options.extraHeaders, }); } return true; } unhold(options = {}, done) { logger.debug('unhold()'); if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) { return false; } if (this._localHold === false) { return false; } if (!this.isReadyToReOffer()) { return false; } this._localHold = false; this._onunhold('local'); const eventHandlers = { succeeded: () => { if (done) { done(); } }, failed: () => { this.terminate({ cause: JsSIP_C.causes.WEBRTC_ERROR, status_code: 500, reason_phrase: 'Unhold Failed', }); }, }; if (options.useUpdate) { this._sendUpdate({ sdpOffer: true, eventHandlers, extraHeaders: options.extraHeaders, }); } else { this._sendReinvite({ eventHandlers, extraHeaders: options.extraHeaders, }); } return true; } renegotiate(options = {}, done) { logger.debug('renegotiate()'); const rtcOfferConstraints = options.rtcOfferConstraints || null; if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) { return false; } if (!this.isReadyToReOffer()) { return false; } const eventHandlers = { succeeded: () => { if (done) { done(); } }, failed: () => { this.terminate({ cause: JsSIP_C.causes.WEBRTC_ERROR, status_code: 500, reason_phrase: 'Media Renegotiation Failed', }); }, }; this._setLocalMediaStatus(); if (options.useUpdate) { this._sendUpdate({ sdpOffer: true, eventHandlers, rtcOfferConstraints, extraHeaders: options.extraHeaders, }); } else { this._sendReinvite({ eventHandlers, rtcOfferConstraints, extraHeaders: options.extraHeaders, }); } return true; } /** * Refer */ refer(target, options) { logger.debug('refer()'); const originalTarget = target; if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) { return false; } // Check target validity. target = this._ua.normalizeTarget(target); if (!target) { throw new TypeError(`Invalid target: ${originalTarget}`); } const referSubscriber = new RTCSession_ReferSubscriber(this); referSubscriber.sendRefer(target, options); // Store in the map. let id = referSubscriber.id; this._referSubscribers[id] = referSubscriber; // Listen for ending events so we can remove it from the map. referSubscriber.on('requestFailed', () => { delete this._referSubscribers[id]; }); referSubscriber.on('accepted', () => { delete this._referSubscribers[id]; }); referSubscriber.on('failed', () => { delete this._referSubscribers[id]; }); referSubscriber.on('authenticated', () => { // Refer request authentication casues CSEQ and hence ID update. delete this._referSubscribers[id]; id = referSubscriber.id; this._referSubscribers[id] = referSubscriber; }); return referSubscriber; } /** * Send a generic in-dialog Request */ sendRequest(method, options) { logger.debug('sendRequest()'); if (this._dialog) { return this._dialog.sendRequest(method, options); } else { const dialogsArray = Object.values(this._earlyDialogs); if (dialogsArray.length > 0) { return dialogsArray[0].sendRequest(method, options); } logger.warn('sendRequest() | no valid early dialog found'); return; } } /** * In dialog Request Reception */ receiveRequest(request) { logger.debug('receiveRequest()'); if (request.method === JsSIP_C.CANCEL) { /* RFC3261 15 States that a UAS may have accepted an invitation while a CANCEL * was in progress and that the UAC MAY continue with the session established by * any 2xx response, or MAY terminate with BYE. JsSIP does continue with the * established session. So the CANCEL is processed only if the session is not yet * established. */ /* * Terminate the whole session in case the user didn't accept (or yet send the answer) * nor reject the request opening the session. */ if (this._status === C.STATUS_WAITING_FOR_ANSWER || this._status === C.STATUS_ANSWERED) { this._status = C.STATUS_CANCELED; this._request.reply(487); this._failed('remote', request, JsSIP_C.causes.CANCELED); } } else { // Requests arriving here are in-dialog requests. switch (request.method) { case JsSIP_C.ACK: { if (this._status !== C.STATUS_WAITING_FOR_ACK) { return; } // Update signaling status. this._status = C.STATUS_CONFIRMED; clearTimeout(this._timers.ackTimer); clearTimeout(this._timers.invite2xxTimer); if (this._late_sdp) { if (!request.body) { this.terminate({ cause: JsSIP_C.causes.MISSING_SDP, status_code: 400, }); break; } const e = { originator: 'remote', type: 'answer', sdp: request.body, }; logger.debug('emit "sdp"'); this.emit('sdp', e); const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp, }); this._connectionPromiseQueue = this._connectionPromiseQueue .then(() => this._connection.setRemoteDescription(answer)) .then(() => { if (!this._is_confirmed) { this._confirmed('remote', request); } }) .catch(error => { this.terminate({ cause: JsSIP_C.causes.BAD_MEDIA_DESCRIPTION, status_code: 488, }); logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); this.emit('peerconnection:setremotedescriptionfailed', error); }); } else if (!this._is_confirmed) { this._confirmed('remote', request); } break; } case JsSIP_C.BYE: { if (this._status === C.STATUS_CONFIRMED || this._status === C.STATUS_WAITING_FOR_ACK) { request.reply(200); this._ended('remote', request, JsSIP_C.causes.BYE); } else if (this._status === C.STATUS_INVITE_RECEIVED || this._status === C.STATUS_WAITING_FOR_ANSWER) { request.reply(200); this._request.reply(487, 'BYE Received'); this._ended('remote', request, JsSIP_C.causes.BYE); } else { request.reply(403, 'Wrong Status'); } break; } case JsSIP_C.INVITE: { if (this._status === C.STATUS_CONFIRMED) { if (request.hasHeader('replaces')) { this._receiveReplaces(request); } else { this._receiveReinvite(request); } } else { request.reply(403, 'Wrong Status'); } break; } case JsSIP_C.INFO: { if (this._status === C.STATUS_1XX_RECEIVED || this._status === C.STATUS_WAITING_FOR_ANSWER || this._status === C.STATUS_ANSWERED || this._status === C.STATUS_WAITING_FOR_ACK || this._status === C.STATUS_CONFIRMED) { const contentType = request.hasHeader('Content-Type') ? request.getHeader('Content-Type').toLowerCase() : undefined; if (contentType && contentType.match(/^application\/dtmf-relay/i)) { new RTCSession_DTMF(this).init_incoming(request); } else if (contentType !== undefined) { new RTCSession_Info(this).init_incoming(request); } else { request.reply(415); } } else { request.reply(403, 'Wrong Status'); } break; } case JsSIP_C.UPDATE: { if (this._status === C.STATUS_CONFIRMED) { this._receiveUpdate(request); } else { request.reply(403, 'Wrong Status'); } break; } case JsSIP_C.REFER: { if (this._status === C.STATUS_CONFIRMED) { this._receiveRefer(request); } else { request.reply(403, 'Wrong Status'); } break; } case JsSIP_C.NOTIFY: { if (this._status === C.STATUS_CONFIRMED) { this._receiveNotify(request); } else { request.reply(403, 'Wrong Status'); } break; } default: { request.reply(501); } } } } /** * Session Callbacks */ onTransportError() { logger.warn('onTransportError()'); if (this._status !== C.STATUS_TERMINATED) { this.terminate({ status_code: 500, reason_phrase: JsSIP_C.causes.CONNECTION_ERROR, cause: JsSIP_C.causes.CONNECTION_ERROR, }); } } onRequestTimeout() { logger.warn('onRequestTimeout()'); if (this._status !== C.STATUS_TERMINATED) { this.terminate({ status_code: 408, reason_phrase: JsSIP_C.causes.REQUEST_TIMEOUT, cause: JsSIP_C.causes.REQUEST_TIMEOUT, }); } } onDialogError() { logger.warn('onDialogError()'); if (this._status !== C.STATUS_TERMINATED) { this.terminate({ status_code: 500, reason_phrase: JsSIP_C.causes.DIALOG_ERROR, cause: JsSIP_C.causes.DIALOG_ERROR, }); } } // Called from DTMF handler. newDTMF(data) { logger.debug('newDTMF()'); this.emit('newDTMF', data); } // Called from Info handler. newInfo(data) { logger.debug('newInfo()'); this.emit('newInfo', data); } /** * Check if RTCSession is ready for an outgoing re-INVITE or UPDATE with SDP. */ isReadyToReOffer() { if (!this._rtcReady) { logger.debug('isReadyToReOffer() | internal WebRTC status not ready'); return false; } // No established yet. if (!this._dialog) { logger.debug('isReadyToReOffer() | session not established yet'); return false; } // Another INVITE transaction is in progress. if (this._dialog.uac_pending_reply === true || this._dialog.uas_pending_reply === true) { logger.debug('isReadyToReOffer() | there is another INVITE/UPDATE transaction in progress'); return false; } return true; } _close() { logger.debug('close()'); // Close local MediaStream if it was not given by the user. if (this._localMediaStream && this._localMediaStreamLocallyGenerated) { logger.debug('close() | closing local MediaStream'); Utils.closeMediaStream(this._localMediaStream); } if (this._status === C.STATUS_TERMINATED) { return; } this._status = C.STATUS_TERMINATED; // Terminate RTC. if (this._connection) { try { this._connection.close(); } catch (error) { logger.warn('close() | error closing the RTCPeerConnection: %o', error); } } // Terminate signaling. // Clear SIP ti