endpoint.js

const assert = require('assert') ;
const delegate = require('delegates') ;
const Emitter = require('events').EventEmitter ;
const Conference = require('./conference') ;
const only = require('only') ;
const _ = require('lodash') ;
const async = require('async') ;
const {parseBodyText} = require('./utils');
const debug = require('debug')('drachtio-fsmrf') ;

const State = {
  NOT_CONNECTED: 1,
  EARLY: 2,
  CONNECTED: 3,
  DISCONNECTED: 4
};

const EVENTS_OF_INTEREST = [
  'CHANNEL_EXECUTE',
  'CHANNEL_EXECUTE_COMPLETE',
  'CHANNEL_PROGRESS_MEDIA',
  'CHANNEL_CALLSTATE',
  'CHANNEL_ANSWER',
  'CUSTOM conference::maintenance'
];

/**
 * A media resource on a freeswitch-based MediaServer that is capable of play,
 * record, signal detection, and signal generation
 * Note: This constructor should not be called directly: rather, call MediaServer#createEndpoint
 * to create an instance of an Endpoint on a MediaServer
 * @constructor
 * @param {esl.Connection} conn - outbound connection from a media server for one session
 * @param {Dialog}   dialog - SIP Dialog to Freeswitch
 * @param {MediaServer}   ms - MediaServer that contains this Endpoint
 * @param {Endpoint~createOptions} [opts] configuration options
 */
class Endpoint extends Emitter {
  constructor(conn, dialog, ms, opts) {
    super() ;


    opts = opts || {} ;
    this._conn = conn ;
    this._ms = ms ;
    this._dialog = dialog ;

    this._dialog.on('destroy', this._onBye.bind(this));

    this.uuid = conn.getInfo().getHeader('Channel-Unique-ID') ;

    /**
     * is secure media being transmitted (i.e. DLTS-SRTP)
     * @type Boolean
     */
    this.secure = /^m=audio\s\d*\sUDP\/TLS\/RTP\/SAVPF/m.test(conn.getInfo().getHeader('variable_switch_r_sdp')) ;

    /**
     * defines the local network connection of the Endpoint
     * @type {Endpoint~NetworkConnection}
     */
    this.local = {} ;
    /**
     * defines the remote network connection of the Endpoint
     * @type {Endpoint~NetworkConnection}
     */
    this.remote = {} ;
    /**
     * defines the SIP signaling parameters of the Endpoint
     * @type {Endpoint~SipInfo}
     */
    this.sip = {} ;

    /**
     * conference name and memberId associated with the conference that the endpoint is currently joined to
     * @type {Object}
     */
    this.conf = {} ;
    this.state = State.NOT_CONNECTED ;

    debug(`Endpoint#ctor creating endpoint with uuid ${this.uuid}, is3pcc: ${opts.is3pcc}`);

    //this._conn.send(`myevents ${this.uuid}\n`);
    //this.conn.subscribe('all');
    this.conn.subscribe(EVENTS_OF_INTEREST.join(' '));
    this.filter('Unique-ID', this.uuid);

    //this.conn.on(`esl::event::CHANNEL_EXECUTE::${this.uuid}`, this._onChannelExecute.bind(this)) ;
    this.conn.on(`esl::event::CHANNEL_HANGUP::${this.uuid}`, this._onHangup.bind(this)) ;
    this.conn.on(`esl::event::CHANNEL_CALLSTATE::${this.uuid}`, this._onChannelCallState.bind(this)) ;

    this.conn.on('error', this._onError.bind(this)) ;

    if (!opts.is3pcc) {
      if (opts.codecs) {
        if (typeof opts.codecs === 'string') opts.codecs = [opts.codecs];
        if (opts.codecs.length > 0) {
          this.execute('set', `codec_string=${opts.codecs.join(',')}`) ;
        }
      }
    }
    this.getChannelVariables(true, (err, obj) => {
      if (err) return console.error(`Endpoint: error accessing channel variables: ${err}`);

      this.local.sdp = obj['variable_rtp_local_sdp_str'] ;
      this.local.mediaIp = obj['variable_local_media_ip'] ;
      this.local.mediaPort = obj['variable_local_media_port'] ;

      this.remote.sdp = obj['variable_switch_r_sdp'] ;
      this.remote.mediaIp = obj['variable_remote_media_ip'] ;
      this.remote.mediaPort = obj['variable_remote_media_port'] ;

      this.dtmfType = obj['variable_dtmf_type'] ;
      this.sip.callId = obj['variable_sip_call_id'] ;

      this.state = State.CONNECTED ;
      this._emitReady();
    }) ;
  }

  /**
   * @return {MediaServer} the mediaserver that contains this endpoint
   */
  get mediaserver() {
    return this._ms ;
  }

  /**
   * @return {Srf} the Srf instance used to send SIP signaling to this endpoint and associated mediaserver
   */
  get srf() {
    return this.ms.srf ;
  }

  /**
   * @return {esl.Connection} the Freeswitch outbound connection used to control this Endpoint
   */
  get conn() {
    return this._conn ;
  }

  get dialog() {
    return this._dialog ;
  }

  set dialog(dlg) {
    if (this._dialog = dlg) this._dialog.on('destroy', this._onBye.bind(this)) ;
    return this ;
  }

  /**
   * set a parameter on the Endpoint
   * @param {String|Object} param parameter name or dictionary of param-value pairs
   * @param {String} value parameter value
   * @param {Endpoint~operationCallback} [callback] callback return results
   * @returns {Promise} a promise is returned if no callback is supplied
   */
  set(param, value, callback) { return setOrExport('set', this, param, value, callback); }

  /**
   * export a parameter on the Endpoint
   * @param {String|Object} param parameter name or dictionary of param-value pairs
   * @param {String} value parameter value
   * @param {Endpoint~operationCallback} [callback] callback return results
   * @returns {Promise} a promise is returned if no callback is supplied
   */
  export(param, value, callback) { return setOrExport('export', this, param, value, callback); }

  /**
   * retrieve channel variables for the endpoint
   * @param  {boolean} [includeMedia] if true, retrieve rtp counters (e.g. variable_rtp_audio_in_raw_bytes, etc)
   * @param  {Endpoint~getChannelVariablesCallback} [callback]  callback function invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  getChannelVariables(includeMedia, callback) {
    if (typeof includeMedia === 'function') {
      callback = includeMedia ;
      includeMedia = false ;
    }

    const __x = (callback) => {
      async.waterfall([
        function setMediaStatsIfRequested(callback) {
          if (includeMedia === true) {
            this.api('uuid_set_media_stats', this.uuid, () => {
              callback(null) ;
            }) ;
          }
          else {
            callback(null) ;
          }
        }.bind(this),
        function getVars(callback) {
          this.api('uuid_dump', this.uuid, (err, event, headers, body) => {
            callback(err, event, headers, body) ;
          }) ;
        }.bind(this)
      ], (err, event, headers, body) => {
        if (err) return callback(err);
        if (headers['Content-Type'] === 'api/response' && 'Content-Length' in headers) {
          var bodyLen = parseInt(headers['Content-Length'], 10) ;
          return callback(null, parseBodyText(body.slice(0, bodyLen))) ;
        }
        callback(null, {}) ;
      }) ;
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, results) => {
        if (err) return reject(err);
        resolve(results);
      });
    });
  }

  /**
   * play an audio file on the endpoint
   * @param  {string|Array}   file file (or array of files) to play
   * @param  {Endpoint~playOperationCallback} [cb]   callback function invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  play(file, callback) {
    assert.ok('string' === typeof file || _.isArray(file), 'file param is required and must be a string or array') ;

    const files = _.isArray(file) ? file : [file] ;

    const __x = (callback) => {
      async.waterfall([
        function setDelimiter(callback) {
          if (1 === files.length) {
            return callback(null);
          }
          this.execute('set', 'playback_delimiter=!', (err, evt) => {
            debug(`Endpoint#play ${this.uuid} playback_delimiter response: ${evt}`) ;
            callback(null);
          }) ;
        }.bind(this),
        function sendPlay(callback) {
          this.execute('playback', files.join('!'), function(err, evt) {
            const result = {
              playbackSeconds: evt.getHeader('variable_playback_seconds'),
              playbackMilliseconds: evt.getHeader('variable_playback_ms'),
            } ;
            callback(null, result) ;
          }) ;
        }.bind(this)
      ], (err, result) => {
        callback(err, result) ;
      }) ;
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }

  /**
   * This callback is invoked when a media operation has completed
   * @callback Endpoint~playOperationCallback
   * @param {Error} err - error returned from play request
   * @param {object} results - results of the operation
   * @param {String} results.playbackSeconds - number of seconds of audio played
   * @param {String} results.playbackMilliseconds - number of fractional milliseconds of audio played
   */

  /**
   * play an audio file and collect digits
   * @param  {Endpoint~playCollectOptions}   opts - playcollect options
   * @param  {Endpoint~playCollectOperationCallback} [callback] - callback function invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  playCollect(opts, callback) {
    assert(typeof opts, 'object', '\'opts\' param is required') ;
    assert(typeof opts.file, 'string', '\'opts.file\' param is required') ;


    const __x = (callback) => {
      opts.min = opts.min || 0 ;
      opts.max = opts.max || 128 ;
      opts.tries = opts.tries || 1 ;
      opts.timeout = opts.timeout || 120000 ;
      opts.terminators = opts.terminators || '#' ;
      opts.invalidFile = opts.invalidFile || 'silence_stream://250' ;
      opts.varName = opts.varName || 'myDigitBuffer' ;
      opts.regexp = opts.regexp || '\\d+' ;
      opts.digitTimeout = opts.digitTimeout || 8000 ;

      const args = [] ;
      ['min', 'max', 'tries', 'timeout', 'terminators', 'file', 'invalidFile', 'varName', 'regexp', 'digitTimeout']
        .forEach((prop) => {
          args.push(opts[prop]) ;
        }) ;

      this.execute('play_and_get_digits', args.join(' '), (err, evt) => {
        if ('play_and_get_digits' !== evt.getHeader('variable_current_application')) {
          return callback(new Error(evt.getHeader('variable_current_application'))) ;
        }
        callback(null, {
          digits: evt.getHeader(`variable_${opts.varName}`),
          invalidDigits: evt.getHeader(`variable_${opts.varName}_invalid`),
          terminatorUsed: evt.getHeader('variable_read_terminator_used'),
          playbackSeconds: evt.getHeader('variable_playback_seconds'),
          playbackMilliseconds: evt.getHeader('variable_playback_ms'),
        }) ;
      }) ;
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });

  }

  /**
   * Speak a phrase that requires grammar rules
   * @param  {string}   text phrase to speak
   * @param  {Endpoint~sayOptions}   opts - say command options
   * @param  {Endpoint~playOperationCallback} [callback] - callback function invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */

  say(text, opts, callback) {
    assert(typeof text, 'string', '\'text\' is required') ;
    assert(typeof opts, 'object', '\'opts\' param is required') ;
    assert(typeof opts.sayType, 'string', '\'opts.sayType\' param is required') ;
    assert(typeof opts.sayMethod, 'string', '\'opts.sayMethod\' param is required') ;

    opts.lang = opts.lang || 'en' ;
    opts.sayType = opts.sayType.toUpperCase() ;
    opts.sayMethod = opts.sayMethod.toLowerCase() ;

    assert.ok(!(opts.sayType in [
      'NUMBER',
      'ITEMS',
      'PERSONS',
      'MESSAGES',
      'CURRENCY',
      'TIME_MEASUREMENT',
      'CURRENT_DATE',
      'CURRENT_TIME',
      'CURRENT_DATE_TIME',
      'TELEPHONE_NUMBER',
      'TELEPHONE_EXTENSION',
      'URL',
      'IP_ADDRESS',
      'EMAIL_ADDRESS',
      'POSTAL_ADDRESS',
      'ACCOUNT_NUMBER',
      'NAME_SPELLED',
      'NAME_PHONETIC',
      'SHORT_DATE_TIME']), 'invalid value for \'sayType\' param: ' + opts.sayType) ;

    assert.ok(!(opts.sayMethod in ['pronounced', 'iterated', 'counted']),
      'invalid value for \'sayMethod\' param: ' + opts.sayMethod) ;

    if (opts.gender) {
      opts.gender = opts.gender.toUpperCase() ;
      assert.ok(opts.gender in ['FEMININE', 'MASCULINE', 'NEUTER'],
        'invalid value for \'gender\' param: ' + opts.gender) ;
    }

    const args = [] ;
    ['lang', 'sayType', 'sayMethod', 'gender'].forEach((prop) => {
      if (opts[prop]) {
        args.push(opts[prop]) ;
      }
    });
    args.push(text) ;

    const __x = (callback) => {
      this.execute('say', args.join(' '), (err, evt) => {
        if ('say' !== evt.getHeader('variable_current_application')) {
          return callback(new Error(`expected response to say but got 
            ${evt.getHeader('variable_current_application')}`)) ;
        }
        debug('Endpoint#say ${this.uuid} response to say command: ', evt) ;
        var result = {
          playbackSeconds: evt.getHeader('variable_playback_seconds'),
          playbackMilliseconds: evt.getHeader('variable_playback_ms'),
        } ;
        callback(null, result) ;
      }) ;
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });

  }

  /**
   * join an endpoint into a conference
   * @param  {String|Conference}   conf - name of a conference or a Conference instance
   * @param  {Endpoint~confJoinOptions}  [opts] - options governing the connection
   * between the endpoint and the conference
   * @param  {Endpoint~confJoinCallback} [callback]  - callback invoked when join operation is completed
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  join(conf, opts, callback) {
    assert.ok(typeof conf === 'string' || conf instanceof Conference,
      'argument \'conf\' must be either a conference name or a Conference object') ;

    const confName = typeof conf === 'string' ? conf : conf.name ;
    if (typeof opts === 'function') {
      callback = opts ;
      opts = {} ;
    }
    opts = opts || {} ;
    opts.flags = opts.flags || {} ;

    const flags = [] ;
    _.each(opts.flags, (value, key) => {
      if (true === value) flags.push(_.snakeCase(key).replace(/_/g, '-'));
    }) ;

    let args = confName ;
    if (opts.profile) args += '@' + opts.profile;
    if (!!opts.pin || flags.length > 0) args += '+' ;
    if (opts.pin) args += opts.pin ;
    if (flags.length > 0) args += '+flags{' + flags.join('|') + '}' ;

    const __x = (callback) => {
      debug(`Endpoint#join: ${this.uuid} executing conference with args: ${args}`) ;

      this.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this)) ;

      this.execute('conference', args) ;

      assert(!this._joinCallback);

      this._joinCallback = (memberId, confUuid) => {
        debug(`Endpoint#joinConference: ${this.uuid} joined ${confName}:${confUuid} with memberId ${memberId}`) ;
        this._joinCallback = null ;
        this.conf.memberId = memberId ;
        this.conf.name = confName;
        this.conf.uuid = confUuid;

        this.conn.removeAllListeners('esl::event::CUSTOM::*') ;

        callback(null, {memberId, confUuid});
      };
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }

  /**
   * bridge two endpoints together
   * @param  {Endpoint | string}   other    - an Endpoint or uuid of a channel to bridge with
   * @param  {Endpoint~operationCallback} [callback] - callback invoked when bridge operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  bridge(other, callback) {
    assert.ok(typeof other === 'string' || other instanceof Endpoint,
      'argument \'other\' must be either a uuid or an Endpoint') ;

    const otherUuid = typeof other === 'string' ? other : other.uuid ;

    const __x = (callback) => {
      this.api('uuid_bridge', [this.uuid, otherUuid], (err, event, headers, body) => {
        if (err) return callback(err);

        if (0 === body.indexOf('+OK')) {
          return callback(null) ;
        }
        callback(new Error(body)) ;
      });
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }


  /**
   * Park an endpoint that is currently bridged with another endpoint
   * @param  {Endpoint~operationCallback} [callback] - callback invoked when bridge operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  unbridge(callback) {
    const __x = (callback) => {
      this.api('uuid_transfer', [this.uuid, '-both', 'park', 'inline'], (err, evt) => {
        if (err) return callback(err);
        const body = evt.getBody() ;
        if (0 === body.indexOf('+OK')) {
          return callback(null) ;
        }
        callback(new Error(body)) ;
      });
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }

  /**
   * call a freeswitch api method
   * @param  {string}   command    command name
   * @param  {string}   [args]     command arguments
   * @param  {Endpoint~mediaOperationsCallback} [callback] callback function
   * @return {Promise|Endpoint}    if no callback specified, a Promise that resolves with the response is returned
   * otherwise a reference to the endpoint object
   */
  api(command, args, callback) {
    if (typeof args === 'function') {
      callback = args ;
      args = [] ;
    }

    const __x = (callback) => {
      debug(`Endpoint#api ${command} ${args}`);
      this._conn.api(command, args, (...response) => {
        debug(`Endpoint#api response: ${JSON.stringify(response).slice(0, 512)}`);
        callback(null, ...response);
      });
    } ;

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, response) => {
        if (err) return reject(err);
        resolve(response);
      });
    });
  }

  /**
   * execute a freeswitch application
   * @param  {string}     app        application name
   * @param  {string}   [arg]      application arguments, if any
   * @param  {Endpoint~mediaOperationsCallback} [callback] callback function
   * @return {Promise|Endpoint}    if no callback specified, a Promise that resolves with the response is returned
   * otherwise a reference to the endpoint object
   */
  execute(app, arg, callback) {
    if (typeof arg === 'function') {
      callback = arg ;
      arg = '';
    }

    const __x = (callback) => {
      debug(`Endpoint#execute ${app} ${arg}`);
      this._conn.execute(app, arg, (evt) => {
        callback(null, evt);
      });
    } ;

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, response) => {
        if (err) return reject(err);
        resolve(response);
      });
    });

  }

  executeAsync(app, arg, callback) {
    return this._conn.execute(app, arg, callback);
  }

  /**
   * Releases an Endpoint and associated resources
   * @param  {Endpoint~operationsCallback} [callback] callback function invoked after endpoint has been released
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  destroy(callback) {
    const __x = (callback) => {
      if (State.CONNECTED !== this.state) {
        return callback(new Error(
          `endpoint ${this.uuid} could not be deleted because it is not connected: ${this.state}`));
      }
      this.state = State.DISCONNECTED ;

      this.dialog.once('destroy', () => {
        debug(`Endpoint#destroy - received BYE for ${this.uuid}`);
        callback(null) ;
      });

      debug(`Endpoint#destroy: executing hangup on ${this.uuid}`);
      this.execute('hangup', (err, evt) => {
        this.conn.disconnect() ;
      });
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }

  // endpoint applications

  /**
   * record the full call
   * @file  {string} file - file to record to
   * @param  {endpointOperationCallback} [callback] - callback invoked with response to record command
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  recordSession(...args) { return endpointApps.recordSession(this, ...args); }

  /**
   * record to a file from the endpoint's input stream
   * @param  {string}   file     file to record to
   * @param  {Endpoint~recordOptions}   opts - record command options
   * @param  {endpointRecordCallback} [callback] - callback invoked with response to record command
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  record(file, opts, callback) {
    if (typeof opts === 'function') {
      callback = opts ;
      opts = {} ;
    }
    opts = opts || {} ;

    const args = [] ;
    ['timeLimitSecs', 'silenceThresh', 'silenceHits'].forEach((p) => {
      if (opts[p]) {
        args.push(opts[p]);
      }
    });

    const __x = (callback) => {
      this.execute('record', `${file} ${args.join(' ')}`, (err, evt) => {
        if (err) callback(err, evt);
        const application = evt.getHeader('Application');
        if ('record' !== application) {
          return callback(new Error(`unexpected application in record response: ${application}`)) ;
        }

        callback(null, {
          terminatorUsed: evt.getHeader('variable_playback_terminator_used'),
          recordSeconds: evt.getHeader('variable_record_seconds'),
          recordMilliseconds: evt.getHeader('variable_record_ms'),
          recordSamples: evt.getHeader('variable_record_samples'),
        }) ;
      }) ;
    } ;

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  }

  // conference member operations

  /**
   * mute the member
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  confMute(...args) { return confOperations.mute(this, ...args); }

  /**
   * unmute the member
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  confUnmute(...args) { return confOperations.unmute(this, ...args); }

  /**
   * deaf the member
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */

  confDeaf(...args) { return confOperations.deaf(this, ...args);  }
  /**
   * undeaf the member
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  confUndeaf(...args) { return confOperations.undeaf(this, ...args);  }

  /**
   * kick the member out of the conference
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  confKick(...args) { return confOperations.kick(this, ...args); }

  /**
   * kick the member out of the conference without exit sound
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the Endpoint object
   */
  confHup(...args) { return confOperations.hup(this, ...args); }

  /**
   * play a file to the member
   * @param string file - file to play
   * @param {Endpoint~playOptions} [opts] - play options
   * @param  {Endpoint~mediaOperationCallback} [callback] - callback invoked when operation completes
   * @return {Promise|Endpoint} returns a Promise if no callback supplied; otherwise
   * a reference to the ConferenceConnection object
   */
  confPlay(...args) { return confOperations.play(this, ...args); }

  /**
   * transfer a member to a new conference
   * @param  {String}   newConf - name of new conference to transfer to
   * @param  {ConferenceConnection~mediaOperationsCallback} [cb] - callback invoked when transfer has completed
   */
  transfer(...args) { return confOperations.transfer(this, ...args); }

  __onConferenceEvent(evt) {
    const eventName = evt.getHeader('Event-Subclass') ;

    if (eventName === 'conference::maintenance') {
      const action = evt.getHeader('Action') ;
      debug(`Endpoint#__onConferenceEvent: conference event action: ${action}`) ;

      //invoke a handler for this action, if we have defined one
      (Endpoint.prototype['_on' + _.capitalize(_.camelCase(action))] || this._unhandled).bind(this, evt)() ;

    }
    else {
      debug(`Endpoint#__onConferenceEvent: got unhandled custom event: ${eventName}`) ;
    }
  }

  _onAddMember(evt) {
    let memberId = -1;
    const confUuid = evt.getHeader('Conference-Unique-ID');
    try {
      memberId = parseInt(evt.getHeader('Member-ID'));
    } catch (err) {
      debug(`Endpoint#_onAddMember: error parsing memberId as an int: ${memberId}`);
    }
    debug(`Endpoint#_onAddMember: memberId ${memberId} conference uuid ${confUuid}`) ;
    assert.ok(typeof this._joinCallback, 'function');
    this._joinCallback(memberId, confUuid) ;
  }

  _unhandled(evt) {
    debug(`unhandled Conference event for endpoint ${this.uuid} with action: ${evt.getHeader('Action')}`) ;
  }

  _onError(err) {
    if (err.errno && (err.errno === 'ECONNRESET' || err.errno === 'EPIPE') && this.state === State.DISCONNECTED) {
      debug('ignoring connection reset error during teardown of connection') ;
      return ;
    }
    console.error(`Endpoint#_onError: uuid: ${this.uuid}: ${err}`) ;
  }

  _onChannelCallState(evt) {
    const channelCallState = evt.getHeader('Channel-Call-State')  ;

    debug(`Endpoint#_onChannelCallState ${this.uuid}: Channel-Call-State: ${channelCallState}`) ;
    if (State.NOT_CONNECTED === this.state && 'EARLY' === channelCallState) {
      this.state = State.EARLY ;

      // if we are using DLTS-SRTP, the 200 OK has been sent at this point;
      // however, answer will not be sent by FSW until the handshake.
      // We need to invoke the callback provided in the constructor now
      // in order to allow the calling app to access the endpoint.
      if (this.secure) {
        this.getChannelVariables(true, (obj) => {
          this.local.sdp = obj['variable_rtp_local_sdp_str'] ;
          this.local.mediaIp = obj['variable_local_media_ip'] ;
          this.local.mediaPort = obj['variable_local_media_port'] ;

          this.remote.sdp = obj['variable_switch_r_sdp'] ;
          this.remote.mediaIp = obj['variable_remote_media_ip'] ;
          this.remote.mediaPort = obj['variable_remote_media_port'] ;

          this.dtmfType = obj['variable_dtmf_type'] ;
          this.sip.callId = obj['variable_sip_call_id'] ;

          this.emitReady() ;
        }) ;
      }
    }

    this.emit('channelCallState', {state: channelCallState});
  }

  _emitReady() {
    if (!this._ready) {
      this._ready = true ;
      setImmediate(() => {
        this.emit('ready');
      });
    }
  }

  _onHangup(evt) {
    if (State.DISCONNECTED !== this.state) {
      this.conn.disconnect();
    }
    this.state = State.DISCONNECTED ;
    this.emit('hangup', evt) ;
  }

  _onBye(evt) {
    debug('Endpoint#_onBye: got BYE from media server') ;
    this.emit('destroy') ;
  }

  toJSON() {
    return only(this, 'sip local remote uuid') ;
  }

  toString() {
    return this.toJSON().toString() ;
  }
}

/**
 * Options governing the creation of an Endpoint
 * @typedef {Object} Endpoint~createOptions
 * @property {string} [debugDir] directory into which message trace files;
 * the presence of this param will enable debug tracing
 * @property {string|array} [codecs] preferred codecs; array order indicates order of preference
 *
 */

/**
 * This callback is invoked when an endpoint has been created and is ready for commands.
 * @callback Endpoint~createCallback
 * @param {Error} error
 * @param {Endpoint} ep the Endpoint
 */

/**
 * Options governing a play command
 * @typedef {Object} Endpoint~playCollectOptions
 * @property {String} file - file to play as a prompt
 * @property {number} [min=0] minimum number of digits to collect
 * @property {number} [max=128] maximum number of digits to collect
 * @property {number} [tries=1] number of times to prompt before returning failure
 * @property {String} [invalidFile=silence_stream://250] file or prompt to play when invalid digits are entered
 * @property {number} [timeout=120000] total timeout in millseconds to wait for digits after prompt completes
 * @property {String} [terminators=#] one or more keys which, if pressed, will terminate
 * digit collection and return collected digits
 * @property {String} [varName=myDigitBuffer] name of freeswitch variable to use to collect digits
 * @property {String} [regexp=\\d+] regular expression to use to govern digit collection
 * @property {number} [digitTimeout=8000] inter-digit timeout, in milliseconds
 */
/**
 * Options governing a record command
 * @typedef {Object} Endpoint~recordOptions
 * @property {number} [timeLimitSecs] max duration of recording in seconds
 * @property {number} [silenceThresh] energy levels below this are considered silence
 * @property {number} [silenceHits] number of packets of silence after which to terminate the recording
 */

/**
 * This callback is invoked when a media operation has completed
 * @callback Endpoint~playCollectOperationCallback
 * @param {Error} err - error returned from play request
 * @param {object} results - results of the operation
 * @param {String} results.digits - digits collected, if any
 * @param {String} results.terminatorUsed - termination key pressed, if any
 * @param {String} results.playbackSeconds - number of seconds of audio played
 * @param {String} results.playbackMilliseconds - number of fractional milliseconds of audio played
 */

/**
 * This callback is invoked when an operation has completed on the endpoint
 * @callback Endpoint~operationCallback
 * @param {Error} err - error returned from play request
 */
/**
 * This callback is invoked when a freeswitch command has completed on the endpoint
 * @callback Endpoint~mediaOperationsCallback
 * @param {Error} err - error returned from play request
 * @param {object} results freeswitch results
 */

/**
 * Speak a phrase that requires grammar rules
 * @param  {string}   text phrase to speak
 * @param  {Endpoint~sayOptions}   opts - say command options
 * @param  {Endpoint~playOperationCallback} cb - callback function invoked when operation completes
 */

/**
 * Options governing a say command
 * @typedef {Object} Endpoint~sayOptions
 * @property {String} sayType describes the type word or phrase that is being spoken;
 * must be one of the following: 'number', 'items', 'persons', 'messages', 'currency', 'time_measurement',
 * 'current_date', 'current_time', 'current_date_time', 'telephone_number', 'telephone_extensio', 'url',
 * 'ip_address', 'email_address', 'postal_address', 'account_number', 'name_spelled',
 * 'name_phonetic', 'short_date_time'.
 * @property {String} sayMethod method of speaking; must be one of the following: 'pronounced', 'iterated', 'counted'.
 * @property {String} [lang=en] language to speak
 * @property {String} [gender] gender of voice to use, if provided must be one of: 'feminine','masculine','neuter'.
 */

/**
 * Options governing a join operation between an endpoint and a conference
 * @typedef {Object} Endpoint~confJoinOptions
 * @property {string} [pin] entry pin for the conference
 * @property {string} [profile=default] conference profile to use
 * @property {Object} [flags] parameters governing the connection of the endpoint to the conference
 * @property {boolean} [flags.mute=false] enter the conference muted
 * @property {boolean} [flags.deaf=false] enter the conference deaf'ed (can not hear)
 * @property {boolean} [flags.muteDetect=false] Play the mute_detect_sound when
 * talking detected by this conferee while muted
 * @property {boolean} [flags.distDtmf=false] Send any DTMF from this member to all participants
 * @property {boolean} [flags.moderator=false] Flag member as a moderator
 * @property {boolean} [flags.nomoh=false] Disable music on hold when this member is the only member in the conference
 * @property {boolean} [flags.endconf=false] Ends conference when all
 * members with this flag leave the conference after profile param endconf-grace-time has expired
 * @property {boolean} [flags.mintwo=false] End conference when it drops below
 * 2 participants after a member enters with this flag
 * @property {boolean} [flags.ghost=false] Do not count member in conference tally
 * @property {boolean} [flags.joinOnly=false] Only allow joining a conference that already exists
 * @property {boolean} [flags.positional=false] Process this member for positional audio on stereo outputs
 * @property {boolean} [flags.noPositional=false] Do not process this member for positional audio on stereo outputs
 * @property {boolean} [flags.joinVidFloor=false] Locks member as the video floor holder
 * @property {boolean} [flags.noMinimizeEncoding] Bypass the video transcode minimizer
 * and encode the video individually for this member
 * @property {boolean} [flags.vmute=false] Enter conference video muted
 * @property {boolean} [flags.secondScreen=false] Open a 'view only' connection to the conference,
 * without impacting the conference count or data.
 * @property {boolean} [flags.waitMod=false] Members will wait (with music) until a member
 * with the 'moderator' flag set enters the conference
 * @property {boolean} [flags.audioAlways=false] Do not use energy detection to choose which
 * participants to mix; instead always mix audio from all members
 * @property {boolean} [flags.videoBridgeFirstTwo=false] In mux mode, If there are only 2 people
 * in conference, you will see only the other member
 * @property {boolean} [flags.videoMuxingPersonalCanvas=false] In mux mode, each member will get their own canvas
 * and they will not see themselves
 * @property {boolean} [flags.videoRequiredForCanvas=false] Only video participants will be
 * shown on the canvas (no avatars)
 */
/**
 * This callback is invoked when a join operation between an Endpoint and a conference has completed
 * @callback Endpoint~joinOperationCallback
 * @param {Error} err - error returned from join request
 * @param {ConferenceConnection} conn - object representing the connection of this participant to the conference
 */

/**
 * This callback is invoked when an endpoint has been destroyed / released.
 * @callback Endpoint~destroyCallback
 * @param {Error} error, if any
 */


/** execute a freeswitch application on the endpoint
* @method Endpoint#execute
* @param {string} app - application to execute
* @param {string | Array} [args] - arguments
* @param {Endpoint~mediaOperationCallback} cb - callback invoked when a
* CHANNEL_EXECUTE_COMPLETE is received for  the application
 */
/** returns true if the Endpoint is in the 'connected' state
*   @name Endpoint#connected
*   @method
*/

/** modify the endpoint by changing attributes of the media connection
*   @name Endpoint#modify
*   @method
*   @param  {string} sdp - 'hold', 'unhold', or a session description protocol
*   @param  {Endpoint~modifyCallback} [callback] - callback invoked when operation has completed
*/
/**
 * This callback provides the response to a join request.
 * @callback Endpoint~confJoinCallback
 * @param {Error} err  error returned from freeswitch, if any
 * @param {Object} obj an object containing {memberId, conferenceUuid} properties
 */
/**
 * This callback provides the response to a modifySession request.
 * @callback Endpoint~modifyCallback
 * @param {Error} err  non-success sip response code received from far end
 */
/**
 * This callback provides the response to a endpoint operation request of some kind.
 * @callback Endpoint~endpointOperationCallback
 * @param {Error} err - null if operation succeeds; otherwises provides an indication of the error
 */
/**
 * This callback is invoked when the response is received to a command executed on the endpoint
 * @callback Endpoint~mediaOperationCallback
 * @param {Error} err  error returned from freeswitch, if any
 * @param {Object} response - response to the command
 */
/**
 * This callback is invoked when the response is received to a command executed on the endpoint
 * @callback Endpoint~getChannelVariablesCallback
 * @param {Error} err  error returned from freeswitch, if any
 * @param {Object} obj - an object with key-value pairs where the key is channel variable name
 * and the value is the associated value
 */

/**
 * Information describing either the local or remote end of a connection to an Endpoint
 * @typedef {Object} Endpoint~NetworkConnection
 * @property {String} sdp - session description protocol offered
 */
/**
 * Information describing the SIP Dialog that established the Endpoint
 * @typedef {Object} Endpoint~SipInfo
 * @property {String} callId - SIP Call-ID
 */
/**
 * destroy event triggered when the Endpoint is destroyed by the media server.
 * @event Endpoint#destroy
 */


delegate(Endpoint.prototype, '_conn')
  .method('connected')
  .method('filter') ;

delegate(Endpoint.prototype, '_dialog')
  .method('request')
  .method('modify') ;

module.exports = exports = Endpoint ;

const confOperations = {} ;

// conference member unary operations
['mute', 'unmute', 'deaf', 'undeaf', 'kick', 'hup', 'tmute', 'vmute', 'unvmute',
  'vmute-snap', 'saymember', 'dtmf'].forEach((op) => {
  confOperations[op] = (endpoint, args, callback) => {
    assert(endpoint instanceof Endpoint);
    if (typeof args === 'function') {
      callback = args ;
      args = '' ;
    }
    args = args || '';
    if (Array.isArray(args)) args = args.join(' ');

    debug(`Endpoint#conf${_.startCase(op)} endpoint ${endpoint.uuid} memberId ${endpoint.conf.memberId}`);
    const __x = (callback) => {
      if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference'));
      endpoint.api('conference', `${endpoint.conf.name} ${op} ${endpoint.conf.memberId} ${args}`, (err, evt) => {
        if (err) return callback(err, evt);
        const body = evt.getBody() ;
        if (-1 !== ['mute', 'deaf', 'unmute', 'undeaf', 'kick', 'tmute', 'vmute', 'unvmute',
          'vmute-snap', 'dtmf'].indexOf(op)) {
          if (/^OK\s+/.test(body)) return callback(err, body);
          return callback(new Error(body));
        }
        return callback(err, evt);
      }) ;
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  };
});

// alias
Endpoint.prototype.unjoin = Endpoint.prototype.confKick ;

confOperations.play = (endpoint, file, opts, callback) => {
  debug(`Endpoint#confPlay endpoint ${endpoint.uuid} memberId ${endpoint.conf.memberId}`);
  assert.ok(typeof file === 'string', '\'file\' is required and must be a file to play') ;

  if (typeof opts === 'function') {
    callback = opts ;
    opts = {} ;
  }
  opts = opts || {} ;

  const __x = (callback) => {
    if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference'));

    const args = [] ;
    if (opts.vol) args.push('vol=' + opts.volume) ;
    if (opts.fullScreen) args.push('full-screen=' + opts.fullScreen) ;
    if (opts.pngMs) args.push('png_ms=' + opts.pngMs) ;
    const s1 = args.length ? args.join(',') + ' ' : '';
    const cmdArgs = `${endpoint.conf.name} play ${file} ${s1} ${endpoint.conf.memberId}`;

    endpoint.api('conference', cmdArgs, (err, evt) => {
      const body = evt.getBody() ;
      if (/Playing file.*to member/.test(body)) return callback(null, evt);
      callback(new Error(body));
    });
  };

  if (callback) {
    __x(callback) ;
    return this ;
  }

  return new Promise((resolve, reject) => {
    __x((err, results) => {
      if (err) return reject(err);
      resolve(results);
    });
  });
};

confOperations.transfer = (endpoint, newConf, callback) => {
  const confName = newConf instanceof Conference ? newConf.name : newConf;
  assert.ok(typeof confName === 'string', '\'newConf\' is required and is the name of the conference to transfer to') ;

  const __x = (callback) => {
    if (!endpoint.conf.memberId) return callback(new Error('Endpoint not in conference'));

    endpoint.api('conference', `${endpoint.conf.name} transfer ${confName} ${endpoint.conf.memberId}`, (err, evt) => {
      if (err) return callback(err, evt);
      const body = evt.getBody() ;
      if (/^OK Member.*sent to conference/.test(body)) return callback(null, body);
      callback(new Error(body));
    }) ;
  };

  if (callback) {
    __x(callback) ;
    return this ;
  }

  return new Promise((resolve, reject) => {
    __x((err, result) => {
      if (err) return reject(err);
      resolve(result);
    });
  });
};

function setOrExport(which, endpoint, param, value, callback) {
  assert(which === 'set' || which === 'export');
  assert(typeof param === 'string' ||
    (typeof param === 'object' && (typeof value == 'function' || typeof value === 'undefined')));

  const obj = {} ;
  if (typeof param === 'string') obj[param] = value ;
  else {
    Object.assign(obj, param) ;
    callback = value ;
  }

  const __x = (callback) => {
    async.eachOf(obj, (value, key, callback) => {
      endpoint.execute(which, `${key}=${value}`, callback);
    }, (err) => {
      callback(err);
    }) ;
  } ;

  if (callback) {
    __x(callback) ;
    return endpoint ;
  }

  return new Promise((resolve, reject) => {
    __x((err, results) => {
      if (err) return reject(err);
      resolve(results);
    });
  });
}

const endpointApps = {} ;

_.each({
  'recordSession': 'record_session'
}, (value, key) => {
  endpointApps[key] = (endpoint, ...args) => {
    const len = args.length ;
    let argList = args ;
    let callback = null ;

    if (typeof args[len - 1] === 'function') {
      argList = args.slice(0, len - 1);
      callback = args[len - 1];
    }
    const __x = (callback) => {
      endpoint.execute(value, argList.join(' '), callback);
    };

    if (callback) {
      __x(callback) ;
      return this ;
    }

    return new Promise((resolve, reject) => {
      __x((err, result) => {
        if (err) return reject(err);
        resolve(result);
      });
    });
  };
});