jssip
Version:
The Javascript SIP library
1,296 lines • 101 kB
JavaScript
"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