Source: modules/nxmCall.js

'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;