'use strict'; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); /* * Nexmo Client SDK * NXMCall Object Model * * Copyright (c) Nexmo Inc. */ const WildEmitter = require('wildemitter'); const loglevel_1 = require("loglevel"); const nexmoClientError_1 = require("../nexmoClientError"); const rtc_helper_1 = __importDefault(require("./rtc_helper")); /** * Conversation NXMCall Object. * @class NXMCall * @param {Application} application - The Application object. * @param {Conversation} conversation - The Conversation object that belongs to this nxmCall. * @param {Member} from - The member that initiated the nxmCall. * @property {Application} application - The Application object that the nxmCall belongs to. * @property {Conversation} conversation - The Conversation object that belongs to this nxmCall. * @property {Member} from - The caller. The member object of the caller (not a reference to the one in conversation.members) * @property {Map<string, Member>} to - The callees keyed by a member's id. The members that receive the nxmCall (not a reference to conversation.members) * @property {String} id - The nxmCall id (our member's leg_id, comes from rtc:answer event, or member:media) * @property {NXMCall.CALL_STATUS} CALL_STATUS="started" - the available nxmCall statuses * @property {NXMCall.CALL_DIRECTION} direction - the Direction of the nxmCall, Outbound, Inbound * @property {NXMCall.STATUS_PERMITTED_FLOW} STATUS_PERMITTED_FLOW - the permitted nxmCall status transition map, describes the "from" and allowed "to" transitions * @property {object[]} rtcObjects data related to the rtc connection * @property {string} rtcObjects.rtc_id the rtc_id * @property {PeerConnection} rtcObjects.pc the current PeerConnection object * @property {Stream} rtcObjects.stream the stream of the specific rtc_id * @property {string} [rtcObjects.type] audio the type of the stream * @property {number} rtcObjects.streamIndex the index number of the stream (e.g. use to mute) * @property {Stream} stream the remote stream * @emits Application#member:call * @emits Application#call:status:changed */ /** * Application listening for member call events. * * @event Application#member:call * * @property {Member} member - the member that initiated the nxmCall * @property {NXMCall} nxmCall - resolves the nxmCall object * * @example <caption>listen for member call events on Application level</caption> * application.on("member:call", (member, nxmCall) => { * console.log("NXMCall ", nxmCall); * }); */ /** * Application listening for nxmCall status changed events. * * @event Application#call:status:changed * @property {NXMCall} nxmCall - the actual event * @example <caption>listen for nxmCall status changed events on Application level</caption> * application.on("call:status:changed",(nxmCall) => { * console.log("call: " + nxmCall.status); * }); */ class NXMCall { constructor(application, conversation, from) { this.application = application; this.log = loglevel_1.getLogger(this.constructor.name); this.from = from; this.conversation = null; this.rtcObjects = {}; /** * Enum for NXMCall status. * @readonly * @enum {string} * @alias NXMCall.CALL_STATUS */ this.CALL_STATUS = { /** The NXMCall is in started status */ STARTED: 'started', /** The NXMCall is in ringing status */ RINGING: 'ringing', /** The NXMCall is in answered status */ ANSWERED: 'answered', /** The NXMCall is in completed status */ COMPLETED: 'completed', /** The NXMCall is in busy status */ BUSY: 'busy', /** The NXMCall is in timeout status */ TIMEOUT: 'timeout', /** The NXMCall is in unanswered status */ UNANSWERED: 'unanswered', /** The NXMCall is in rejected status */ REJECTED: 'rejected', /** The NXMCall is in failed status */ FAILED: 'failed' }; /** * Enum for NXMCall direction. * @readonly * @enum {string} * @alias NXMCall.CALL_DIRECTION */ this.CALL_DIRECTION = { /** The NXMCall started from another end */ INBOUND: 'inbound', /** The NXMCall started from this client */ OUTBOUND: 'outbound' }; Object.freeze(this.CALL_DIRECTION); /** * Enum for the permitted call status transition. * @readonly * @alias NXMCall.STATUS_PERMITTED_FLOW * @enum {Map<string, Set<NXMCall.CALL_STATUS>>} */ this.STATUS_PERMITTED_FLOW = new Map([ /** Permitted transition array from STARTED */ ['STARTED', new Set([ this.CALL_STATUS.RINGING, this.CALL_STATUS.ANSWERED, this.CALL_STATUS.FAILED, this.CALL_STATUS.TIMEOUT, this.CALL_STATUS.UNANSWERED, this.CALL_STATUS.REJECTED, this.CALL_STATUS.BUSY ])], /** Permitted transition array from RINGING */ ['RINGING', new Set([ this.CALL_STATUS.ANSWERED, this.CALL_STATUS.FAILED, this.CALL_STATUS.TIMEOUT, this.CALL_STATUS.UNANSWERED, this.CALL_STATUS.REJECTED, this.CALL_STATUS.BUSY ])], /** Permitted transition set from ANSWERED */ ['ANSWERED', new Set([ this.CALL_STATUS.COMPLETED, this.CALL_STATUS.FAILED ])] ]); Object.freeze(this.STATUS_PERMITTED_FLOW); this.status = null; this.call_disconnect_timeout = null; this.direction = this.CALL_DIRECTION.INBOUND; this._setupConversationObject(conversation); WildEmitter.mixin(NXMCall); } /** * Enable NXMCall stats to be emitted in * - application.inAppCall.on('rtcstats:report') * - application.inAppCall.on('rtcstats:analytics') * @private */ _enableStatsEvents() { this.conversation.media._enableStatsEvents(); } /** * Attach member event listeners from the conversation * @private */ _attachCallListeners() { // Conversation level listeners this.log.debug("_attachCallListeners : ", { nxmCall: this }); try { this.conversation.releaseGroup('call_module'); this.conversation.on('member:media', 'call_module', (from, event) => { if (this.application.calls && this.application.calls.has(this.conversation.id)) { this.application.calls.get(this.conversation.id)._handleStatusChange(event); } }); } catch (e) { this.log.error("_attachCallListeners_error: ", { e }); } } /** * Validate the current nxmCall status transition * If a transition is not defined, return false * @param {string} status the status to validate * @returns {boolean} false if the transition is not permitted * @private */ _isValidStatusTransition(status) { if (!status) { throw new nexmoClientError_1.NexmoClientError(`Provide the status to validate the transition from '${this.status}'`); } // if the nxmCall object is just initialised allow any state if (!this.status) { return true; } const current_status = this.status.toUpperCase(); if (!this.STATUS_PERMITTED_FLOW.has(current_status)) { return false; } if (this.status === status) { return false; } return (this.STATUS_PERMITTED_FLOW.get(current_status).has(status)); } /** * Go through the members of the conversation and if .me is the only one (JOINED or INVITED) * nxmCall nxmCall.hangUp(). * @returns {Promise} - empty promise or the nxmCall.hangUp promise chain */ hangUpIfAllLeft() { this.log.debug("hangUpIfAllLeft: ", { nxmCall: this }); if (!this.conversation.me || this.conversation.me.state === 'LEFT' || this.conversation.members.size <= 1) { return Promise.resolve(); } for (let member of this.conversation.members.values()) { if (member.state !== 'LEFT' && (this.conversation.me.user.id !== member.user.id)) { return Promise.resolve(); } } return this.hangUp(); } /** * Set the conversation object of the NXMCall * update nxmCall.from, and nxmCall.to attributes based on the conversation members * @private */ _setupConversationObject(conversation, rtc_id) { if (!conversation) return; this.conversation = conversation; if (!conversation.me) { this.log.warn('missing own member object'); } else { this.to = new Map(conversation.members); if (this.from) { this.to.delete(this.from.id); } } // Attch Conversation Listeners this._attachCallListeners(); } /** * Set the from object of the NXMCall * @private */ _setFrom(from) { this.from = from; } /** * Set the from object of the NXMCall * @private */ _setOffer(offer) { this.offer = offer; } /** * Process raw events to figure out the nxmCall status * @private */ _handleStatusChange(event) { var _a; // for knocking case the conversation object is not yet set in the nxmCall. We know the action is initiated from us const _isEventFromMe = (this.conversation) ? ((_a = this.conversation.me) === null || _a === void 0 ? void 0 : _a.id) === event.from : true; const _isOutbound = this.direction === this.CALL_DIRECTION.OUTBOUND; this.log.debug("_handleStatusChange: ", { event }, `_isEventFromMe: ${_isEventFromMe} _isOutbound: ${_isOutbound}`); let _handleStatusChangeMap = new Map(); _handleStatusChangeMap.set('member:joined', async () => { if (event.body.channel && event.body.channel.id) { try { this._setStatusAndEmit(this.CALL_STATUS.STARTED); return; } catch (error) { this._setStatusAndEmit(this.CALL_STATUS.FAILED); this.log.error(error); throw error; } } return Promise.resolve(); }); _handleStatusChangeMap.set('member:invited', () => { if (event.body.invited_by === null && event.body.user.media && event.body.user.media.audio_settings) { this._setStatusAndEmit(this.CALL_STATUS.STARTED); } return Promise.resolve(); }); _handleStatusChangeMap.set('rtc:hangup', () => { if (this.status === this.CALL_STATUS.ANSWERED) { this._setStatusAndEmit(this.CALL_STATUS.COMPLETED); return Promise.resolve(); } else { if (_isEventFromMe && _isOutbound || !_isEventFromMe && !_isOutbound) { this._setStatusAndEmit(this.CALL_STATUS.UNANSWERED); return Promise.resolve(); } else { this._setStatusAndEmit(this.CALL_STATUS.REJECTED); return Promise.resolve(); } } }); _handleStatusChangeMap.set('member:left', () => { if (!event.body.timestamp.hasOwnProperty('joined') && this.status !== this.CALL_STATUS.ANSWERED) { if (_isEventFromMe && _isOutbound || !_isEventFromMe && !_isOutbound) { this._setStatusAndEmit(this.CALL_STATUS.UNANSWERED); return Promise.resolve(); } else { this._setStatusAndEmit(this.CALL_STATUS.REJECTED); return Promise.resolve(); } } }); _handleStatusChangeMap.set('member:media', () => { if (this.status !== this.CALL_STATUS.ANSWERED && event.body.audio) { if (_isEventFromMe && event.body.channel) { this.id = event.body.channel.id; } if ((!_isEventFromMe || !_isOutbound) && this.id) { this._setStatusAndEmit(this.CALL_STATUS.ANSWERED); } } return Promise.resolve(); }); _handleStatusChangeMap.set('sip:ringing', () => { if (this.status !== this.CALL_STATUS.RINGING) { this._setStatusAndEmit(this.CALL_STATUS.RINGING); } return Promise.resolve(); }); _handleStatusChangeMap.set('sip:hangup', () => { switch (event.body.reason.sip_code) { case 486: this._setStatusAndEmit(this.CALL_STATUS.BUSY); break; case 487: this._setStatusAndEmit(this.CALL_STATUS.TIMEOUT); break; case 403: this._setStatusAndEmit(this.CALL_STATUS.FAILED); break; } return Promise.resolve(); }); _handleStatusChangeMap.set('knocking:delete:success', () => { this._setStatusAndEmit(this.CALL_STATUS.UNANSWERED); return Promise.resolve(); }); if (_handleStatusChangeMap.has(event.type)) { return _handleStatusChangeMap.get(event.type).call(this); } } /** * Set the nxmCall.status and emit a call:status:changed event * * @param {NXMCall.CALL_STATUS} this.CALL_STATUS the canxmCallll status to set * @emits Application#call:status:changed * @private */ _setStatusAndEmit(status) { if (!this._isValidStatusTransition(status)) { return; } this.status = status; this.log.debug(`_setStatusAndEmit: ${status}`, { nxmCall: this }); this.application.emit('call:status:changed', this); } /** * Answers an incoming nxmCall * Join the conversation that you are invited * Create autoplay Audio object * * @param {boolean} [autoPlayAudio=true] attach the audio stream automatically to start playing (default true) * @returns {Promise<Audio>} */ async answer(autoPlayAudio = true) { this.log.debug(`answer: { autoPlayAudio: ${autoPlayAudio}`); if (this.conversation) { try { await this.conversation.join(); const stream = await this.conversation.media.enable({ autoPlayAudio, offer: this.offer }); this.offer = undefined; return stream; } catch (error) { this._setStatusAndEmit(this.CALL_STATUS.FAILED); this.log.error(error); throw error; } } else { throw new nexmoClientError_1.NexmoClientError('error:call:answer'); } } /** * Trigger the nxmCall flow for the input users. * Create a conversation with prefix name "CALL_" * and invite all the users. * If at least one user is successfully invited, enable the audio. * * @param {string[]} usernames the usernames of the users to call * @param {boolean} [autoPlayAudio=true] attach the audio stream automatically to start playing (default true) * @returns {Promise[]} an array of the invite promises for the provided usernames * @private */ async createCall(usernames, autoPlayAudio = true) { this.log.debug(`createCall: { usernames: ${usernames}, autoPlayAudio: ${autoPlayAudio} }`); if (!usernames || !Array.isArray(usernames) || usernames.length === 0) { return Promise.reject(new nexmoClientError_1.NexmoClientError('error:application:call:params')); } try { const conversation = await this.application.newConversationAndJoin({ display_name: 'CALL_' + this.application.me.name + '_' + usernames.join('_').replace(' ', '') }); conversation.members.set(conversation.me.id, conversation.me); this.from = conversation.me; this.successful_invited_members = new Map(); const invites = usernames.map(async (username) => { // check all invites, if at least one is resolved enable audio // we need to catch rejections to allow all the chain to go through (all invites) // we then catch-reject a promise so that the errors are passing through the end of the chain try { const member = await conversation.inviteWithAudio({ user_name: username }); conversation.members.set(member.id, member); this.successful_invited_members.set(member.id, member); return member; } catch (error) { this.log.error(error); // resolve the error to allow the promise.all to collect // and return all the promises return error; } }); // helper function to process in Promise.all() the failed invites too const process_invites = async () => { if (this.successful_invited_members.size > 0) { await conversation.media.enable({ audio: { muted: false, earmuffed: false }, autoPlayAudio }); this.application.calls.set(conversation.id, this); return invites; } else { throw invites; } }; // we need to continue the invites even if one fails, // in process_invites we do the check if at least one was successful await Promise.all(invites); this._setupConversationObject(conversation); return await process_invites(); } catch (error) { this.log.error(error); this._setStatusAndEmit(this.CALL_STATUS.FAILED); throw error; } } /** * Trigger the nxmCall flow for the phone call. * Create a knocking event * * @param {string} user the phone number or the username to call * @param {string} type the type of the call you want to have. possible values "phone" or "app" (default is "phone") * @returns {Promise} * @private */ async createServerCall(user, type, custom_data) { this.log.debug(`createServerCall: { user: ${user}, type: ${type}, custom_data: `, { custom_data }); const to = { type }; if (type === 'phone') { to.number = user; } else { to.user = user; } try { // PrewarmLeg const { stream, legId, rtcObjects } = await rtc_helper_1.default.prewarmLeg(this); this.log.debug("createServerCall: ", { stream }, { legId }, { rtcObjects }); // Add Media to the Call Object this.rtcObjects = rtcObjects; this.stream = stream; this.id = legId; // Add leg_id to the call draft list this.application._call_draft_list.set(legId, this); rtc_helper_1.default.playAudioStream(stream); const params = { type: 'POST', path: 'knocking', data: { channel: { type: 'app', from: { type: 'app' }, to, id: legId || null }, ...(custom_data && Object.keys(custom_data).length && { properties: { custom_data } }) } }; try { const knockingResponse = await this.application.session.sendNetworkRequest(params); this.knocking_id = knockingResponse.id; } catch (error) { throw new nexmoClientError_1.NexmoApiError(error); } // If knocking request doesn't result in member:joined after set time disable audio, cleanup media rtc_helper_1.default.cleanCallMediaIfFailed(this); return stream; } catch (error) { // If knocking request fails disable audio, cleanup media rtc_helper_1.default.cleanMediaProperties(this); throw error; } } /** * Hangs up the nxmCall * * If there is a knocking active, do a knocking:delete * otherwise * Leave from the conversation * Disable the audio * * @param {object} [reason] the reason for hanging up the nxmCall * @param {string} [reason.reason_code] the code of the reason * @param {string} [reason.reason_text] the description of the reason * @returns {Promise} */ async hangUp(reason) { this.log.debug(`hangUp: { reason: ${reason} }`); if (this.conversation) { await this.conversation.media.disable(); } if (!this.knocking_id && this.conversation) { return this.conversation.leave(reason).catch(error => { if (error.type !== "conversation:error:invalid-member-state") { return Promise.reject(error); } return; }); } else { let path = `knocking/${this.knocking_id}`; if (reason) { let params = new URLSearchParams(); Object.keys(reason).forEach((key) => { params.append(key, reason[key]); }); path += `?${params.toString()}`; } try { const response = await this.application.session.sendNetworkRequest({ type: 'DELETE', path }); const nxmCall = this.application._call_draft_list.get(this.client_ref); nxmCall._handleStatusChange(response); this.application._call_draft_list.delete(this.client_ref); return response; } catch (error) { // Don't switch yet to fail status, it could be an expected race between knocking:delete and conversation.leave if (!this.conversation) { this.log.debug('hangup: Problem cancelling the call. Knocking cancel failed and Conversation. Leave not available', error); return; } else { this.log.error(new nexmoClientError_1.NexmoApiError(error)); return this.conversation.leave(reason).catch(error => { if (error.type !== "conversation:error:invalid-member-state") { return Promise.reject(error); } return; }); } } } } /** * Rejects an incoming nxmCall * Leave from the conversation that you are invited * * @param {object} [reason] the reason for rejecting the nxmCall * @param {string} [reason.reason_code] the code of the reason * @param {string} [reason.reason_text] the description of the reason * @returns {Promise} */ reject(reason) { this.log.debug(`reject: { reason: ${reason} }`); if (this.conversation) { return this.conversation.leave(reason); } else { return Promise.reject(new nexmoClientError_1.NexmoClientError('error:call:reject')); } } } exports.default = NXMCall; module.exports = NXMCall;