Home Reference Source

src/conferences/Conference.js

import {Map, Set} from 'immutable';
import {BehaviorSubject} from 'rxjs';
import {stringify} from 'query-string';
import {v4 as uuid} from 'uuid';

import {EventEmitter} from '../eventemitter';
import {bindMethods} from '../utils';

import {ManagedStream} from '../media/ManagedStream';
import {LocalMediaHandler} from '../media/LocalMediaHandler';
import {ConferenceEndReason as EndReason} from './ConferenceEndReason';
import {ConferenceStatus as Status} from './ConferenceStatus';
import {Invitation} from './Invitation';
import {
	ParticipantEvents,
	StreamChangeEvents,
} from '../wac-proxy/stacks/ConferenceEvents';

import Logs from '../Logs';
import {ConferenceInvite} from './ConferenceInvite';
const log = Logs.instance.getLogger('SippoJS/calls/Conference');

const ALREADY_JOINED_ERROR = 'ALREADY_JOINED';
const CALL_TRANSFER_START_EVENT = 'callTransferStart';
const TEMPORARILY_UNAVAILABLE_RESPONSE = 480;
const OK_RESPONSE = 200;

const existUserButNotConnected = (code, invitation) =>
	code === 404 && typeof invitation.getToUser() === 'object';

const getGatewayUri = (session, gtUsername) => {
	return session ? gtUsername + `/${session}` : gtUsername;
};

const userStatusAsKeyValue = ({user, status}) => ({[user]: status});

const makePlainUserStatus = (users) => {
	const reducer = (acc, user) =>
		Object.assign(acc, userStatusAsKeyValue(user));
	return users.reduce(reducer, {});
};

/**
 * @typedef {string} gatewayUsername
 */

/**
 * @typedef {Object} EventInfo
 * @property {string} participant gatewayUsername of a participant in the conference
 * @property {string} [reason='IN_CALL'|'HOLD'] reason to know if the participant is on hold or not
 */

/**
 * @typedef {Object} LocalStreamEvent
 * @property {string} participant gatewayUsername of the local stream
 * @property {@link ManagedStream} stream
 */

/**
 * @typedef {Object} RemoteStreamAddedEvent
 * @property {string} participant gatewayUsername of the local stream
 * @property {string} [reason='NEW'|'UNHOLD'] reason of stream addition
 * @property {@link ManagedStream} stream
 */

/**
 * @typedef {Object} RemoteStreamRemovedEvent
 * @property {string} participant gatewayUsername of the local stream
 * @property {string} [reason='GONE'|'HOLD'] reason of stream addition
 * @property {@link ManagedStream} stream
 */

/**
 * Provides access to methods for managing outgoing or incoming conferences.
 * {@link Conference} objects are obtained calling {@link ConferenceManager#createConference} method or handling
 * the `onIncoming` event of a connected {@link ConferenceManager} instance.
 *
 * ## Events
 *
 * - `remoteStreams` ({@link ImmutableSet}<{@link ManagedStream}> currentValue, {@link ImmutableSet}<{@link ManagedStream}> oldValue, {@link EventInfo})
 *    - Emitted every time remoteStreams changes.
 * - `outgoingInvitations` ({@link ImmutableSet}<{@link Invitation}> currentValue, {@link Invitation}<{@link ManagedStream}> oldValue)
 *    - Emitted every a outgoingInvitation changes.
 * - `expelled`
 *    - Emitted when we are expelled from the conference.
 *      deprecated
 * - `destroyed`
 *    - Emitted when the conference is destroyed.
 *      deprecated
 * - `status` ({@link ConferenceStatus} currentValue, {@link ConferenceStatus}<{@link ManagedStream}> oldValue)
 *    - Emitted every time the conference status changes.
 *      deprecated
 * - `remoteParticipants` ({@link ImmutableMap}<{@link gatewayUsername}, 'IN_CALL'|'HOLD'> currentValue, {@link ImmutableMap}<{@link gatewayUsername}, 'IN_CALL'|'HOLD'> oldValue)
 *   - Emitted every time the participants list changes.
 * - `transferError`
 *   - Emitted when an error occurs in a transfer.
 * - `participantAdded` ({@link gatewayUsername} gatewayUsername)
 *   - Emitted when a new participant joins the conference
 * - `participantRemoved` ({@link gatewayUsername} gatewayUsername)
 *   - Emitted when a participant leaves the conference
 * - `participantHold` ({@link gatewayUsername} gatewayUsername)
 *   - Emitted when a participant starts holding the call
 * - `participantUnhold` ({@link gatewayUsername} gatewayUsername)
 *   - Emitted when a participant stops holding the call
 * - `localStreamAdded` ({@link LocalStreamEvent})
 *   - Emitted on a new local stream
 * - `localStreamRemoved` ({@link LocalStreamEvent})
 *   - Emitted on local stream removed
 * - `remoteStreamAdded` ({@link RemoteStreamAddedEvent})
 *   - Emitted on remote stream added
 * - `remoteStreamRemove` ({@link RemoteStreamRemovedEvent})
 *   - Emitted on remote stream removed
 */
export class Conference {
	/** @private */
	constructor(userManager, room, kManageService, mediaConstraints) {
		this._userManager = userManager;
		this._room = room;
		this._kManageService = kManageService;
		this._status = new BehaviorSubject(Status.DISCONNECTED);
		this._endReason = EndReason.UNSPECIFIED;
		this._localMediaHandler = new LocalMediaHandler(mediaConstraints, this.updateMedia.bind(this));
		this._transferingCall = false;
		this._outgoingInvitations = new Set();
		this._remoteParticipants = new Map();
		this._remoteStreams = new Map();
		this._remoteWacAddresses = new Set();
		this._joinTimestamp = undefined;

		/**
		 * Use to keep the number of invitations sent in this conference
		 * @type {boolean}
		 */
		this.invitationCount = 0;

		/** @type {EventEmitter} */
		this.emitter = new EventEmitter({newListener: true});
		this.emitter.on('newListener', (eventName) => {
			if (['status', 'destroyed', 'expelled'].includes(eventName)) {
				log.warn(`${eventName} is deprecated. Please use getStatus() and/or getStatus$() instead`);
			}
		});

		bindMethods(this, [
			'_onLocalStreamChanged',
			'_onLocalStreamAdded',
			'_onLocalStreamRemoved',
			'_onRemoteStreamChanged',
			'_onRemoteStreamAdded',
			'_onRemoteStreamRemoved',
			'_onExpelled',
			'_onTransferingCall',
			'_onDestroyed',
			'_onParticipantAdded',
			'_onParticipantRemoved',
			'_onParticipantHold',
			'_onParticipantUnhold',
		]);

		// Asume at least someone was invited for recovered conferences
		if (room.recovered) {
			this.invitationCount++;
		}
	}

	/**
	 * @protected
	 * @param {UserManager} userManager
	 * @param {Room} room
	 * @param {MediaStreamConstraints} mediaConstraints
	 */
	static async create(userManager, room, kManageService, mediaConstraints) {
		const conference = new Conference(userManager, room, kManageService, mediaConstraints);
		conference._bindEventHandlers();
		conference._owner = await userManager.resolveUser(room.owner);
		await conference._localMediaHandler.init();
		return conference;
	}

	/**
	 * @private
	 */
	_bindEventHandlers() {
		this._room.on(StreamChangeEvents.LOCAL_STREAM_CHANGED, this._onLocalStreamChanged);
		this._room.on(StreamChangeEvents.LOCAL_STREAM_ADDED, this._onLocalStreamAdded);
		this._room.on(StreamChangeEvents.LOCAL_STREAM_REMOVED, this._onLocalStreamRemoved);
		this._room.on(StreamChangeEvents.REMOTE_STREAM_CHANGED, this._onRemoteStreamChanged);
		this._room.on(StreamChangeEvents.REMOTE_STREAM_ADDED, this._onRemoteStreamAdded);
		this._room.on(StreamChangeEvents.REMOTE_STREAM_REMOVED, this._onRemoteStreamRemoved);
		this._room.on('expelled', this._onExpelled);
		this._room.on('transferingCall', this._onTransferingCall);
		this._room.on('conference-destroyed', this._onDestroyed);
		this._room.on(ParticipantEvents.PARTICIPANT_ADDED, this._onParticipantAdded);
		this._room.on(ParticipantEvents.PARTICIPANT_REMOVED, this._onParticipantRemoved);
		this._room.on(ParticipantEvents.PARTICIPANT_HOLD, this._onParticipantHold);
		this._room.on(ParticipantEvents.PARTICIPANT_UNHOLD, this._onParticipantUnhold);
	}

	/**
	 * @private
	 */
	_unbindEventHandlers() {
		this._room.off(StreamChangeEvents.LOCAL_STREAM_CHANGED, this._onLocalStreamChanged);
		this._room.off(StreamChangeEvents.LOCAL_STREAM_ADDED, this._onLocalStreamAdded);
		this._room.off(StreamChangeEvents.LOCAL_STREAM_REMOVED, this._onLocalStreamRemoved);
		this._room.off(StreamChangeEvents.REMOTE_STREAM_CHANGED, this._onRemoteStreamChanged);
		this._room.off(StreamChangeEvents.REMOTE_STREAM_ADDED, this._onRemoteStreamAdded);
		this._room.off(StreamChangeEvents.REMOTE_STREAM_REMOVED, this._onRemoteStreamRemoved);
		this._room.off('expelled', this._onExpelled);
		this._room.off('transferingCall', this._onTransferingCall);
		this._room.off('conference-destroyed', this._onDestroyed);
		this._room.off(ParticipantEvents.PARTICIPANT_ADDED, this._onParticipantAdded);
		this._room.off(ParticipantEvents.PARTICIPANT_REMOVED, this._onParticipantRemoved);
		this._room.off(ParticipantEvents.PARTICIPANT_HOLD, this._onParticipantHold);
		this._room.off(ParticipantEvents.PARTICIPANT_UNHOLD, this._onParticipantUnhold);
	}

	/**
	 * @private
	 */
	_onLocalStreamChanged(event) {
		if (event.streams && event.streams.length > 0) {
			this._localMediaHandler.setAudioOnlyStream(event.streams.find(s => s.getAudioTracks().length > 0));
			this._localMediaHandler.setVideoStream(event.streams.find(s => s.getVideoTracks().length > 0));
			// Detect stopped screen track
			this._localMediaHandler.getVideoStream().getScreenTracks().forEach((screenTrack) => {
				screenTrack.onended = () => this._localMediaHandler.toggleScreen(false);
			});
		} else {
			this._localMediaHandler.setVideoStream();
			this._localMediaHandler.setAudioOnlyStream();
		}
	}


	/**
	 * @private
	 */
	_onLocalStreamAdded({participant, stream}) {
		this.emitter.emit(StreamChangeEvents.LOCAL_STREAM_ADDED, {
			participant,
			stream: new ManagedStream(stream, true),
		});
	}

	/**
	 * @private
	 */
	_onLocalStreamRemoved({participant, stream}) {
		this.emitter.emit(StreamChangeEvents.LOCAL_STREAM_REMOVED, {
			participant,
			stream: new ManagedStream(stream, true),
		});
	}

	/**
	 * @private
	 */
	_onRemoteStreamAdded({participant, stream, reason}) {
		this.emitter.emit(StreamChangeEvents.REMOTE_STREAM_ADDED, {
			participant,
			reason,
			stream: this._getRemoteStream(stream) || new ManagedStream(stream),
		});
	}

	/**
	 * @private
	 */
	_getRemoteStream(stream) {
		for (const remoteStreams of this._remoteStreams.values()) {
			for (const remoteStream of remoteStreams) {
				if (remoteStream && remoteStream.mediaStream === stream) {
					return remoteStream;
				}
			}
		}
	}

	/**
	 * @private
	 */
	_onRemoteStreamRemoved({participant, stream, reason}) {
		this.emitter.emit(StreamChangeEvents.REMOTE_STREAM_REMOVED, {
			participant,
			reason,
			stream: this._getRemoteStream(stream) || new ManagedStream(stream),
		});
	}

	/**
	 * @private
	 */
	_onRemoteStreamChanged(event) {
		return Promise.resolve(event.participant ? this._userManager.resolveUser(event.participant) : null).then((user) => {
			const oldRemoteStreams = this._remoteStreams;
			let invitationToSetStreamsTrue;
			if (!event.streams || event.streams.length <= 0) {
				this._remoteStreams = this._remoteStreams.delete(event.participant);
				if (user) {
					this._remoteWacAddresses = this._remoteWacAddresses.delete(user.getAddress());
				}
			} else {
				let managedStreams = event.streams.map(stream => this._getRemoteStream(stream) || new ManagedStream(stream));
				this._remoteStreams = this._remoteStreams.set(event.participant, managedStreams);
				if (user) {
					this._remoteWacAddresses = this._remoteWacAddresses.add(user.getAddress());
				}
				invitationToSetStreamsTrue = this._getOutgoingInvitation(event.participant);
			}
			this.emitter.emit('remoteStreams', this._remoteStreams, oldRemoteStreams, {
				participant: event.participant,
				reason: event.reason,
			});
			if (invitationToSetStreamsTrue) {
				this._updateOutgoingInvitations(invitationToSetStreamsTrue.setStreams(true), invitationToSetStreamsTrue);
			}
		});
	}

	/**
	 * @private
	 */
	_onExpelled() {
		this._localMediaHandler.setVideoStream();
		this._localMediaHandler.setAudioOnlyStream();
		this._remoteStreams = new Map();
		this.emitter.emit('expelled');
		this._setStatus(Status.DESTROYED);
	}

	/**
	 * @private
	 */
	_onDestroyed() {
		this._localMediaHandler.setVideoStream();
		this._localMediaHandler.setAudioOnlyStream();
		this._remoteStreams = new Map();
		this.emitter.emit('destroyed');
		this._setStatus(Status.DESTROYED);
	}

	/**
	 * @private
	 */
	_onTransferingCall(event) {
		this._setTransferingCall(true);
		return this._room.acceptCallTransfer(event.payload.callTransferID).then(() => {
			this.emitter.emit(CALL_TRANSFER_START_EVENT);
			return this.inviteParticipant(event.payload.to, undefined, event.payload.mediatypes).then((response) => {
				if (response != 200) {
					this.emitter.emit('transferError', response);
				}
			}).finally(() => {
				this._setTransferingCall(false);
			});
		});
	}

	/**
	 * @private
	 */
	async _onParticipantAdded(participantObj) {
		log.debug(`onParticipantAdded(participant) : ${participantObj}`);
		this._updateParticipants(participants =>
			participants.set(participantObj.user, participantObj.status));
		this.emitter.emit(ParticipantEvents.PARTICIPANT_ADDED, participantObj.user);
	}

	_updateParticipants(updater) {
		const oldValue = this.getRemoteParticipants();
		const currentValue = updater(oldValue);
		this._remoteParticipants = currentValue;
		this.emitter.emit('remoteParticipants', currentValue, oldValue);
		return currentValue;
	}

	/**
	 * @private
	 */
	async _onParticipantRemoved(participant) {
		log.debug(`onParticipantRemoved(participant) : ${participant}`);
		this._updateParticipants(participants =>
			participants.delete(participant));
		this.emitter.emit(ParticipantEvents.PARTICIPANT_REMOVED, participant);
	}

	/**
	 * @private
	 * @param {string} participant - Participant user name
	 */
	_onParticipantHold(participant) {
		log.debug(`onParticipantHold(participant) : ${participant}`);
		this._updateParticipants(participants =>
			participants.set(participant, 'HOLD'));
		this.emitter.emit(ParticipantEvents.PARTICIPANT_HOLD, participant);
	}

	/**
	 * @private
	 * @param {string} participant - Participant user name
	 */
	_onParticipantUnhold(participant) {
		log.debug(`onParticipantUnhold(participant) : ${participant}`);
		this._updateParticipants(participants =>
			participants.set(participant, 'IN_CALL'));
		this.emitter.emit(ParticipantEvents.PARTICIPANT_UNHOLD, participant);
	}

	/**
	 * @private
	 * @param {object} [options]
	 * @param {string} [options.skill] - Ability of the agent.
	 * @param {string} [options.token] - Third-party call ID.
	 */
	_buildAgentUri(options = {}) {
		const queryString = stringify(options);
		return `wac-agent:${uuid()}?${queryString}`;
	}

	/**
	 * Manages the Invitations Set and events.
	 * @private
	 */
	_updateOutgoingInvitations(invitationToAdd, invitationToRemove) {
		const oldOutgoingInvitations = this._outgoingInvitations;
		if (invitationToRemove) {
			this._outgoingInvitations = this._outgoingInvitations.delete(invitationToRemove);
		}
		if (invitationToAdd) {
			this._outgoingInvitations = this._outgoingInvitations.add(invitationToAdd);
		}
		this.emitter.emit('outgoingInvitations', this._outgoingInvitations, oldOutgoingInvitations);
	}

	/**
	 * @private
	 */
	_getOutgoingInvitation(gatewayUsername) {
		return this._outgoingInvitations.find((invitation) => {
			return invitation.getTo() === gatewayUsername;
		});
	}

	/**
	 * @private
	 */
	_hasActiveInvitation(gatewayUsername) {
		return this._outgoingInvitations.some((i) => {
			return i.getTo() === gatewayUsername && i.isActive();
		});
	}

	/**
	 * This is assuming that if it does not has an invitation answered 200, it could never been in the room
	 * Made to solve PSTN problems (users with early media cannot be re-invited)
	 * TODO We need a proper way to check if the user is already joined
	 * @private
	 */
	_alreadyJoined(gatewayUsername) {
		const invitation = this._getOutgoingInvitation(gatewayUsername);
		if (invitation) {
			return invitation.getResponseCode() === OK_RESPONSE && this._remoteStreams.has(gatewayUsername);
		} else {
			return this._remoteStreams.has(gatewayUsername);
		}
	}

	/**
	 * @private
	 */
	_canSendInvitation(gatewayUsername) {
		return !this._hasActiveInvitation(gatewayUsername) && !this._alreadyJoined(gatewayUsername);
	}

	/**
	 * Returns the ID of the conference
	 * @return {string}
	 */
	getId() {
		return this._room.id;
	}

	/**
	 * Returns the remote participants
	 * @return {ImmutableMap<string, 'IN_CALL'|'HOLD'>}
	 */
	getRemoteParticipants() {
		return this._remoteParticipants;
	}

	/**
	 * @private
	 */
	async fetchRemoteParticipants() {
		const myAddress = this._userManager.getOwnAddress();
		const myUser = await this._userManager.resolveUser(myAddress);
		const notMyUser = ({user}) => user !== myUser.gatewayUsername;
		const participantRetrieved = await this._room.getParticipants();
		const remoteParticipants = participantRetrieved.filter(notMyUser);
		const plainRemoteParticipants = makePlainUserStatus(remoteParticipants);
		this._remoteParticipants = Map(plainRemoteParticipants);
		return this._remoteParticipants;
	}

	/**
	 * Returns the current status of the conference
	 * @return {ConferenceStatus}
	 */
	getStatus() {
		return this._status.value;
	}

	/**
	 * Returns an observable for the status of the conference
	 * @return {Observable<ConferenceStatus>}
	 */
	getStatus$() {
		return this._status.asObservable();
	}

	/**
	 * Returns the username of the conference's owner
	 * @return {string}
	 */
	getOwner() {
		return this._owner;
	}

	/**
	 * Allows access to local streams and actions related to changing which local media is shared
	 * @return {LocalMediaHandler}
	 */
	getLocalMediaHandler() {
		return this._localMediaHandler;
	}

	/**
	 * Returns a Map containing a list of ManagedStreams per participant (gateway user name)
	 * @return {ImmutableMap<string, ManagedStream[]>}
	 */
	getRemoteStreams() {
		return this._remoteStreams;
	}

	/**
	 * Returns a Set of strings containing the gateway username of every
	 * remote participant
	 * @return {ImmutableSet<string>}
	 */
	getRemoteGatewayUsernames() {
		return new Set(this._remoteStreams.keySeq().filter(x => !!x));
	}

	/**
	 * Returns a Set of strings containing the address of remote participants (gateway user names)
	 * that are also wac users
	 * @return {ImmutableSet<string>}
	 */
	getRemoteWacAddresses() {
		return this._remoteWacAddresses;
	}

	/**
	 * Returns a Set of outgoing invitations
	 * @return {ImmutableSet<Invitation>}
	 */
	getOutgoingInvitations() {
		return this._outgoingInvitations;
	}

	/**
	 * Check if a conference is being transfering
	 * @return {boolean}
	 */
	isBeingTransfered() {
		return this._transferingCall;
	}

	updateMedia(audio, video, screen) {
		if (this.getStatus() === Status.CONNECTED) {
			return this._room.update({audio, video, screen});
		}
		return Promise.resolve();
	}

	/**
	 * Returns a boolean value indicating if at least one of the remote conference participants
	 * has a video track
	 * @return {boolean}
	 */
	hasRemoteVideo() {
		for (let [, [stream]] of this._remoteStreams) {
			if (stream.hasVideoTracks()) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @private
	 * @param {ConferenceStatus} status
	 */
	_setStatus(status) {
		const oldStatus = this.getStatus();
		this._status.next(status);
		this.emitter.emit('status', status, oldStatus);
		if (status === Status.DISCONNECTED) {
			this._unbindEventHandlers();
		}
	}

	/**
	 * @private
	 * @param {boolean} value
	 */
	_setTransferingCall(value) {
		this._transferingCall = value;
	}

	/**
	 * Indicates if participants can be invited to this conference or not
	 * @return {boolean}
	 */
	canManage() {
		return this._room.isOwner() || !this._room.hasOwner();
	}

	/**
	 * @return {boolean}
	 */
	isRecovered() {
		return this._room.recovered;
	}

	/**
	 * Returns the current participant count.
	 * Includes in the count the users with active invitations.
	 * @return {number}
	 */
	getCurrentParticipants() {
		var participantCount = this._remoteStreams.size;
		this._outgoingInvitations.forEach((invitation) => {
			if (invitation.isActive()) {
				participantCount++;
			}
		});
		return participantCount;
	}

	/**
	 * Returns the invitation to the conference if the participant was invited.
	 * @return {ConferenceInvite}
	 */
	getInvite() {
		if (this._room.invite) {
			const {id, from, mediatypes, context} = this._room.invite;
			return new ConferenceInvite(id, from, mediatypes, context);
		}
		return undefined;
	}

	/**
	 * Invites a new participant to this conference
	 * @throws {Error} throw error when a participant has already invited or is joined to the conference.
	 * @param {String} participant
	 * @param {String} [session]
	 * @param {MediaTypes} [mediaTypes] the MediaTypes of this invitation
	 * @param {boolean} [autoAccepted=false]
	 * @param {string} [requestId] the ID of the request that generated this invitation.
	 * @param {boolean} [resolve=true] indicates if participant address should be resolved to an user
	 * gatewayUsername.
	 * @param {object} [context] Arbitrary context that can be sent when making an invite
	 * @returns {Promise<Object>} A promise with an object with two properties: SIP code and whoAccepted the invite
	 * @property {number} code SIP code of the response
	 * @property {string} whoAccepted User id of the participant that accepted the invite
	 */
	async inviteParticipant(participant, session, mediaTypes, autoAccepted = false, requestId, resolve = true, context = {}) {
		if (!mediaTypes) {
			mediaTypes = this._localMediaHandler.getMediaTypes();
			mediaTypes.screen = false;
		}
		let gatewayUsername;
		return this._userManager.resolveUser(participant).then((user) => {
			log.debug('calls/Conference.js/inviteParticipant/resolveUser', participant, user);
			gatewayUsername = resolve && user && user.gatewayUsername ? user.gatewayUsername : participant;

			if (!this._canSendInvitation(gatewayUsername)) {
				log.debug('calls/Conference.js/inviteParticipant/cannotSendInvite', gatewayUsername);
				throw new Error(ALREADY_JOINED_ERROR);
			}

			this._updateOutgoingInvitations(
				new Invitation(user, gatewayUsername, mediaTypes, autoAccepted, undefined, false, false),
				this._getOutgoingInvitation(gatewayUsername));

			this.invitationCount++;
			return this._room.inviteParticipant(getGatewayUri(session, gatewayUsername), mediaTypes, requestId, context);
		}).then((response) => {
			log.debug('calls/Conference.js/inviteParticipant/room.inviteParticipant/response', response);
			const oldInvitation = this._getOutgoingInvitation(gatewayUsername);
			if (existUserButNotConnected(response.code, oldInvitation)) {
				response.code = TEMPORARILY_UNAVAILABLE_RESPONSE;
			}
			let newInvitation = oldInvitation.setResponseCode(response.code);
			if (response.whoAccepted) {
				gatewayUsername = response.whoAccepted;
				newInvitation = newInvitation.setTo(response.whoAccepted);
			}
			if (this._remoteStreams.get(gatewayUsername)) {
				newInvitation = newInvitation.setStreams(true);
			}
			this._updateOutgoingInvitations(newInvitation, oldInvitation);
			return response;
		}).catch((error) => {
			log.error('calls/Conference.js/inviteParticipant', error);
			if (error.message !== ALREADY_JOINED_ERROR) {
				const oldInvitation = this._getOutgoingInvitation(gatewayUsername);
				if (oldInvitation) {
					this._updateOutgoingInvitations(oldInvitation.setCanceled(true), oldInvitation);
				}
			}
			throw error;
		});
	}

	/**
	 * Invites many participants to a conference.
	 * @param {InviteParticipants} participants
	 * @param {String} [session]
	 * @param {MediaTypes} [mediaTypes] the MediaTypes for this invitation
	 * @param {boolean} [autoAccepted= false]
	 * @param {boolean} [resolve=true] indicates if participant address should be resolved to an user
	 * gatewayUsername.
	 * @param {object} [context] Arbitrary context that can be sent when making an invite
	 * @returns {Promise<Object[]>} A promise with an array of invitation objects
	 * @property {Promise<Object>} waitUntilResolve Will resolve once the invitation is answered. The resolution will have two fields: code (a SIP code) and whoAccepted (userId of the participant who accepted)
	 */
	async inviteMany(participants, session, mediaTypes, autoAccepted = false, resolve = true, context = {}) {
		if (!mediaTypes) {
			mediaTypes = this._localMediaHandler.getMediaTypes();
			mediaTypes.screen = false;
		}

		const users = await Promise.all(participants.map(async (p) => {
			const user = await this._userManager.resolveUser(p.to);
			return {
				user,
				participant: p.to,
			};
		}));
		const gatewayUsernames = users.map((user) => {
			return resolve && user.user && user.user.gatewayUsername
				? user.user.gatewayUsername
				: participants.find(par => par.to === user.participant);
		});
		const realParticipants = gatewayUsernames.map(gwUser => ({to: getGatewayUri(session, gwUser), inviteId: Math.random().toString()}));

		this.updateOutgoingInvitationsForParticipants(gatewayUsernames, mediaTypes, autoAccepted);

		const asUpdatingMultiple = invite => ({
			...invite,
			waitUntilResolved: async () => {
				const response = await invite.waitInvitationResponse();
				invite.removePending();
				this.updateOutgoingResolvedInvitationsForParticipant(invite.to, response);
				return response;
			},
		});
		const resolvedInvites = await this._room.inviteMany(realParticipants, mediaTypes, context);
		return resolvedInvites.map(asUpdatingMultiple.bind(this));
	}

	updateOutgoingInvitationsForParticipants(gatewayUsernames, mediaTypes, autoAccepted) {
		for (const participant of gatewayUsernames) {
			this._updateOutgoingInvitations(
				new Invitation(participant, participant, mediaTypes, autoAccepted, undefined, false, false),
				this._getOutgoingInvitation(participant),
			);
			this.invitationCount++;
		}
	}

	updateOutgoingResolvedInvitationsForParticipant(participant, resolvedInvite) {
		log.debug('calls/Conference.js/inviteParticipant/room.inviteParticipant/response', resolvedInvite);
		const oldInvitation = this._getOutgoingInvitation(participant);
		if (existUserButNotConnected(resolvedInvite.code, oldInvitation)) {
			resolvedInvite.code = TEMPORARILY_UNAVAILABLE_RESPONSE;
		}
		let newInvitation = oldInvitation.setResponseCode(resolvedInvite.code);
		let gatewayUsername = participant;
		if (resolvedInvite.whoAccepted) {
			gatewayUsername = resolvedInvite.whoAccepted;
			newInvitation = newInvitation.setTo(resolvedInvite.whoAccepted);
		}
		if (this._remoteStreams.get(gatewayUsername)) {
			newInvitation = newInvitation.setStreams(true);
		}
		this._updateOutgoingInvitations(newInvitation, oldInvitation);
	}

	/**
	 * Expels a participant from this conference
	 * @param {string} participant
	 * @param {string} [session]
	 * @returns {Promise}
	 */
	expelParticipant(participant, session) {
		if (session) {
			participant += `/${session}`;
		}
		return this._room.expelParticipant(participant);
	}

	/**
	 * Cancels a invitation of this conference
	 *
	 * @param {Invitation} invitation
	 * @param {string} [session]
	 */
	cancelInvitation(invitation, session) {
		if (invitation.isCanceled()) {
			return;
		}
		if (!invitation.getResponseCode()) {
			this._room.cancelInvitation(getGatewayUri(session, invitation.getTo()));
		}
		this._updateOutgoingInvitations(invitation.setCanceled(true), invitation);
	}

	/**
	 * Invites a list of new participant to this conference
	 * @param {string[]} participants
	 * @returns {Promise}
	 */
	inviteParticipants(participants) {
		return Promise.all(participants.map(p => this.inviteParticipant(p)));
	}

	/**
	 * Expels a list of participant from this conference
	 * @param {string[]} participants
	 * @returns {Promise}
	 */
	expelParticipants(participants) {
		return Promise.all(participants.map(p => this.expelParticipant(p)));
	}

	/**
	 * Connects to the conference
	 * @returns {Promise}
	 */
	async join() {
		if (this.getStatus() !== Status.DISCONNECTED) {
			return Promise.reject(Error(`You can't connect to a conference in ${Status[this.getStatus()]} status`));
		}
		this._setStatus(Status.CONNECTING);
		try {
			await this._room.join(this._localMediaHandler.getCurrentMediaConstraints());
			this._joinTimestamp = Date.now();
			await this.fetchRemoteParticipants();
			this._setStatus(Status.CONNECTED);
		} catch (error) {
			this._setStatus(Status.DISCONNECTED);
			this._room.leave();
			throw error;
		}
	}

	/**
	 * Invites an agent to a conference
	 * @param {MediaTypes} mediaTypes the media types that will be used to call an agent
	 * @param {Object} [contextInfo={}] arbitrary context that can be sent to an agent
	 * @param {Object} [token] third-party call ID.
	 * @return {Promise}
	 */
	async callAgent(
		mediaTypes = this._localMediaHandler.getMediaTypes(),
		contextInfo = {},
		token = undefined,
	) {
		const agent = this._buildAgentUri({undefined, token});
		const response = await this.inviteParticipant(
			agent,
			undefined,
			mediaTypes,
			undefined,
			undefined,
			undefined,
			contextInfo,
		);
		if (response.code < 200 || response.code >= 300) {
			throw new Error('Error calling an agent');
		}
		return response;
	}

	/**
	 * Invites an agent with a particular skill to a conference
	 * @param {MediaTypes} mediaTypes the media types that will be used to call an agent
	 * @param {string} skill the skill an agent must have in order to be invited
	 * @param {Object} [contextInfo={}] arbitrary context that can be sent to an agent
	 * @return {Promise}
	 */
	async callSkilledAgent(
		mediaTypes = this._localMediaHandler.getMediaTypes(),
		skill,
		contextInfo = {},
		token = undefined,
	) {
		const agent = this._buildAgentUri({skill, token});
		const response = await this.inviteParticipant(
			agent,
			undefined,
			mediaTypes,
			undefined,
			undefined,
			undefined,
			contextInfo,
		);
		if (response.code < 200 || response.code >= 300) {
			throw new Error(`Error calling an agent with skill ${skill}`);
		}
		return response;
	}

	/**
	 * Returns the timestamp when the join to this conference was completed
	 * @returns {number} timestamp when the join was completed
	 */
	getJoinTimestamp() {
		return this._joinTimestamp;
	}

	/**
	 * Leaves the conference
	 * @param {number} errorCode the rejection cause when an invitation is received
	 * and the conference has not been joined.
	 * @returns {Promise}
	 */
	async leave(errorCode) {
		switch (this.getStatus()) {
			case Status.DESTROYED:
				return;
			case Status.DISCONNECTED:
				return this._room.leave(errorCode);
			case Status.DISCONNECTING:
				throw Error(`You can't disconnect to a conference in ${Status[this.getStatus()]} status`);
		}
		this._setStatus(Status.DISCONNECTING);
		if (this.canManage()) {
			try {
				this._outgoingInvitations.forEach((invitation) => {
					this.cancelInvitation(invitation);
				});
			} catch (error) {
				log.error(error);
			}
		}
		try {
			return this._room.leave();
		} catch (error) {
			if (!error.message.match(/already disconnected/)) {
				throw error;
			}
		} finally {
			this._setStatus(Status.DISCONNECTED);
		}
	}

	/**
	 * Local hold
	 * Stop publishing and subscribing to the media in the conference
	 * @return {Promise} resolved on success
	 */
	async hold() {
		if (this._localMediaHandler.getMediaTypes().screen) {
			await this._localMediaHandler.toggleScreen();
		}
		return Promise.all([
			this._kManageService.hold(this._room.id),
			this._room.hold(),
		]);
	}


	/**
	 * Stop local hold
	 * Republishes all previously published media, subscribes
	 * to previously subscribed media and notifies new status to
	 * other participants.
	 * @return {Promise} resolved on success
	 */
	unhold() {
		return this._room.unhold();
	}

	/**
	 * Check if conference is on hold state.
	 * @type {boolean}
	 */
	get isOnHold() {
		return this._room.isOnHold;
	}

	/**
	 * Create an unattendedCallTransfer towards a user in the system
	 * @param {string} participant
	 * @param {MediaTypes} [mediaTypes] the current media types will be used if nothing is specified
	 * @returns {Promise}
	 */
	unattendedCallTransfer(participant, mediaTypes = this._localMediaHandler.getMediaTypes()) {
		return this._userManager.resolveUser(participant).then((user) => {
			let gatewayUsername = user && user.gatewayUsername ? user.gatewayUsername : participant;
			return this._room.unattendedCallTransfer(gatewayUsername, mediaTypes);
		});
	}

	/**
	 * Send data to the audio's room
	 * @param {string} value
	 * @returns {Promise<undefined, Error}
	 */
	sendDtmf(value) {
		if (this.getStatus() !== Status.CONNECTED) {
			return Promise.reject(new Error('You aren\'t joined to the conference'));
		}
		return this._room.sendDtmf(value);
	}
}