dialog.js

const Emitter = require('events') ;
const assert = require('assert') ;
const only = require('only') ;
const methods = require('sip-methods') ;
const debug = require('debug')('drachtio-srf') ;

/**
 * Arguments provided to Dialog constructor.
 * @typedef {Object} Dialog~Options
 * @property {Object} req drachtio Request (only provided when creating a 'uas' dialogs)
 * @property {Object} res drachtio Response
 * @property {Object} [sent] - uas only: actual Response sent when confirming the dialog
 */

/**
 * Class representing a SIP Dialog.
 *
 * Note that instances of this class are not created directly by your code;
 * rather they are returned from the {@link Srf#createUAC}, {@link Srf#createUAC}, and {@link Srf#createB2BUA}
 * @class
 * @extends EventEmitter
 */
class Dialog extends Emitter {

  /**
   * Constructor that is called internally by Srf when generating a Dialog instance.
   * @param {Srf} srf - Srf instance that created this dialog
   * @param {string} type - type of SIP dialog: 'uac', or 'uas'
   * @param {Dialog~Options} opts
   */
  constructor(srf, type, opts) {
    super() ;

    const types = ['uas', 'uac'] ;
    assert.ok(-1 !== types.indexOf(type), 'argument \'type\' must be one of ' + types.join(',')) ;

    this.srf = srf ;
    this.type = type ;
    this.req = opts.req ;
    this.res = opts.res ;
    this.agent = this.res.agent ;
    this.onHold = false ;
    this.connected = true ;

    /**
     * sip properties that uniquely identify this Dialog
     * @type {Dialog~SipInfo}
     */
    this.sip = {
      callId: this.res.get('Call-ID'),
      remoteTag: 'uas' === type ?
        this.req.getParsedHeader('from').params.tag : this.res.getParsedHeader('to').params.tag,
      localTag: 'uas' === type ?
        opts.sent.getParsedHeader('to').params.tag : this.req.getParsedHeader('from').params.tag
    } ;

    /**
     * local side of the Dialog
     * @type {Dialog~SipEndpointInfo}
     */
    this.local = {
      uri: 'uas' === type ? opts.sent.getParsedHeader('Contact')[0].uri : this.req.uri,
      sdp: 'uas' === type ? opts.sent.body : this.req.body,
      contact: 'uas' === type ? opts.sent.get('Contact') : this.req.get('Contact')
    } ;

    /**
     * local side of the Dialog
     * @type {Dialog~SipEndpointInfo}
     */
    this.remote = {
      uri: 'uas' === type ? this.req.getParsedHeader('Contact')[0].uri : this.res.getParsedHeader('Contact')[0].uri,
      sdp: 'uas' === type ? this.req.body : this.res.body
    } ;
  }

  get id() {
    return this.res.stackDialogId ;
  }

  get dialogType() {
    return this.req.method ;
  }

  get subscribeEvent() {
    return this.dialogType === 'SUBSCRIBE' ? this.req.get('Event') : null ;
  }

  get socket() {
    return this.req.socket;
  }

  toJSON() {
    return only(this, 'id type sip local remote onHold') ;
  }

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

  /**
   * destroy the sip dialog by generating a BYE request (in the case of INVITE dialog),
   * or NOTIFY (in the case of SUBSCRIBE)
   * @param {Dialog~requestCallback=} [callback] callback that returns the generated BYE or NOTIFY message
   * @return {Promise} if no callback is supplied, otherwise the function returns a reference to the Dialog
   */
  destroy(opts, callback) {
    opts = opts || {} ;
    if (typeof opts === 'function') {
      callback = opts ;
      opts = {} ;
    }

    const __x = (callback) => {
      if (this.dialogType === 'INVITE') {
        this.agent.request({
          method: 'BYE',
          headers: opts.headers || {},
          stackDialogId: this.id,
          _socket: this.socket
        }, (err, bye) => {
          this.connected = false ;
          this.srf.removeDialog(this) ;
          callback(err, bye) ;
        }) ;
      }
      else if (this.dialogType === 'SUBSCRIBE') {
        opts.headers = opts.headers || {} ;
        opts.headers['subscription-state'] = 'terminated';
        opts.headers['event'] = this.subscribeEvent ;
        this.agent.request({
          method: 'NOTIFY',
          headers: opts.headers || {},
          stackDialogId: this.id,
          _socket: this.socket
        }, (err, notify) => {
          this.connected = false ;
          this.srf.removeDialog(this) ;
          callback(err, notify) ;
        }) ;
      }
    };

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

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

  /**
   * modify the dialog session by changing attributes of the media connection
   * @param  {string} sdp - 'hold', 'unhold', or a session description protocol
   * @param  {Dialog~modifySessionCallback} [callback] - callback invoked when operation has completed
   * @return {Promise} if no callback is supplied, otherwise the function returns a reference to the Dialog
   */
  modify(sdp, callback) {

    const __x = (callback) => {
      switch (sdp) {
        case 'hold':
          this.local.sdp = this.local.sdp.replace(/a=sendrecv/, 'a=inactive') ;
          this.onHold = true ;
          break ;
        case 'unhold':
          if (this.onHold) {
            this.local.sdp = this.local.sdp.replace(/a=inactive/, 'a=sendrecv') ;
          }
          else {
            console.error('Dialog#modify: attempt to \'unhold\' session which is not on hold');
            return process.nextTick(() => {
              callback(new Error('attempt to unhold session that is not on hold'));
            }) ;
          }
          break ;
        default:
          this.local.sdp = sdp ;
          break ;
      }

      debug('Dialog#modify: sending reINVITE for dialog id: %s, sdp: ', this.id, this.local.sdp) ;
      this.agent.request({
        method: 'INVITE',
        stackDialogId: this.id,
        body: this.local.sdp,
        _socket: this.socket,
        headers: {
          'Contact': this.local.contact
        }
      }, (err, req) => {
        req.on('response', (res, ack) => {
          debug('Dialog#modifySession: received response to reINVITE with status %d', res.status) ;
          if (res.status >= 200) {
            ack() ;
            if (200 === res.status) {
              this.remote.sdp = res.body ;
              return callback(null);
            }
            callback(new Error('' + res.status + ' ' + res.reason, 'E_SIP' + res.status)) ;
          }
        }) ;
      }) ;
    };

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

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

  /**
   * send a request within a dialog.
   * Note that you may also call <code>request.info(..)</code> as a shortcut
   * to send an INFO message, <code>request.notify(..)</code>
   * to send a NOTIFY, etc..
   * @param  {Dialog~requestOptions}   opts - configuration options
   * @param  {Dialog~requestCallback} [callback]  - callback invoked when operation has completed
   * @return {Promise} if no callback is supplied a Promise that resolves to the response received,
   * otherwise the function returns a reference to the Dialog
   */
  request(opts, callback) {
    assert.ok(typeof opts.method === 'string' &&
      -1 !== methods.indexOf(opts.method), '\'opts.method\' is required and must be a SIP method') ;

    const __x = (callback) => {
      var method = opts.method.toUpperCase() ;

      this.agent.request({
        method: method,
        stackDialogId: this.id,
        headers: opts.headers || {},
        _socket: this.socket,
        body: opts.body
      }, (err, req) => {
        if (err) {
          return callback(err) ;
        }

        req.on('response', (res, ack) => {
          if ('BYE' === method) {
            this.srf.removeDialog(this) ;
          }
          if (res.status >= 200) {
            if ('INVITE' === method) { ack() ; }

            if (this.dialogType === 'SUBSCRIBE' && 'NOTIFY' === method &&
              /terminated/.test(req.get('Subscription-State'))) {
              debug('received a 200 OK to a NOTIFY we sent with Subscription-State terminated; dialog is ended') ;
              this.connected = false ;
              this.srf.removeDialog(this) ;
              this.emit('destroy', req.msg) ;
            }
            callback(null, res) ;
          }
        }) ;
      }) ;
    } ;

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

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

  handle(req, res) {
    debug('Dialog handling message: ', req.method) ;
    var eventName = req.method.toLowerCase() ;
    switch (req.method) {
      case 'BYE':
        let reason = 'normal release';
        if (req.meta.source === 'application') {
          if (req.has('Reason')) {
            reason = req.get('Reason');
            const arr = /text=\"(.*)\"/.exec(reason);
            if (arr) reason = arr[1];
          }
        }
        this.connected = false ;
        this.srf.removeDialog(this) ;
        res.send(200) ;
        this.emit('destroy', req.msg, reason) ;
        break ;

      case 'INVITE':
        var origRedacted = this.remote.sdp.replace(/^o=.*$/m, 'o=REDACTED') ;
        var newRedacted = req.body.replace(/^o=.*$/m, 'o=REDACTED') ;
        var refresh = req.body === this.remote.sdp ;
        var hold = origRedacted.replace(/a=sendrecv\r\n/g, 'a=sendonly\r\n') === newRedacted ;
        var unhold = origRedacted.replace(/a=sendonly\r\n/g, 'a=sendrecv\r\n') === newRedacted ;
        var modify = !hold && !unhold && !refresh ;
        this.remote.sdp = req.body ;

        if (refresh) {
          this.emit('refresh', req.msg);
        }
        else if (hold) {
          this.local.sdp = this.local.sdp.replace(/a=sendrecv\r\n/g, 'a=recvonly\r\n') ;
          this.emit('hold', req.msg) ;
        }
        else if (unhold) {
          this.local.sdp = this.local.sdp.replace(/a=recvonly\r\n/g, 'a=sendrecv\r\n') ;
          this.emit('unhold', req.msg) ;
        }
        if ((refresh || hold || unhold) || (modify && 0 === this.listeners('modify').length)) {
          debug('responding with 200 OK to reINVITE') ;
          res.send(200, {
            body: this.local.sdp,
            headers: {
              'Contact': this.local.contact,
              'Content-Type': 'application/sdp'
            }
          }) ;
        }
        else if (modify) {
          this.emit('modify', req, res) ;
        }
        break ;

      case 'NOTIFY':
        // if this is a subscribe dialog and subscription-state: terminated, then destroy the dialog
        if (req.has('subscription-state') && /terminated/.test(req.get('subscription-state'))) {
          setImmediate(() => {
            debug('received a NOTIFY with Subscription-State terminated; dialog is ended') ;
            this.connected = false ;
            this.srf.removeDialog(this) ;
            this.emit('destroy', req.msg) ;
          }) ;
        }
        if (0 === this.listeners(eventName).length) {
          res.send(200) ;
        }
        else {
          this.emit(eventName, req, res) ;
        }
        break ;

      case 'SUBSCRIBE':
      case 'INFO':
      case 'REFER':
      case 'OPTIONS':
      case 'MESSAGE':
      case 'PUBLISH':
      case 'UPDATE':
        if (0 === this.listeners(eventName).length) {
          res.send(200) ;
        }
        else {
          this.emit(eventName, req, res) ;
        }
        break ;

      case 'ACK':
        this.emit('ack', req.msg) ;
        break ;

      default:
        console.error('Dialog#handle received invalid method within an INVITE dialog: %s', req.method) ;
        res.send(501) ;
        break ;
    }

  }
}

module.exports = exports = Dialog ;

methods.forEach((method) => {
  Dialog.prototype[method.toLowerCase()] = (opts, cb) => {
    opts = opts || {} ;
    opts.method = method ;
    return this.request(opts, cb) ;
  };
}) ;


/**
 * @typedef {Object} Dialog~requestOptions
 * @property {String} method - SIP method to use for the request
 * @property {Object} [headers] - SIP headers to apply to the request
 * @property {String} [body] - body of the SIP request
 */
/**
 * This callback provides the response to a sip request sent within the dialog.
 * @callback Dialog~requestCallback
 * @param {Error} err - error, if any
 * @param {Response} response -  response received from the far end
 */


/**
 * This callback provides the SIP request that was generated
 * @callback Dialog~requestCallback
 * @param {Error} err   error returned on non-success
 * @param {Request} req Request that was generated
 */


/**
 * This callback provides the response a modifySession request.
 * @callback Dialog~modifySessionCallback
 * @param {Error} err  non-success sip response code received from far end
 */


/**
 * SIP Dialog identifiers
 * @typedef {Object} Dialog~SipInfo
 * @property {String} callId - SIP Call-ID
 * @property {String} localTag - tag generated by local side of the Dialog
 * @property {String} remoteTag  - tag generated by the remote side of the Dialog
 */
/**
 * SIP Endpoint identifiers
 * @typedef {Object} Dialog~SipEndpointInfo
 * @property {String} uri - sip
 * @property {String} sdp - session description protocol
 */
/**
 * a <code>destroy</code> event is triggered when the Dialog is torn down from the far end
 * @event Dialog#destroy
 * @param {Object} msg - incoming BYE request message
 */
/**
 * a <code>modify</code> event is triggered when the far end modifies the session by sending a re-INVITE.
 * When an application adds a handler for this event it must generate
 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
 * When no handler is found for this event a 200 OK with the current local SDP
 * will be automatically generated.
 *
 * @event Dialog#modify
 * @param {Object} req - drachtio request object
 * @param {Object} res - drachtio response object
 * @memberOf Dialog
 */
/**
 * a <code>refresh</code> event is triggered when the far end sends a session refresh.
 * There is no need for the application to respond to this event; this is purely
 * a notification.
 * @event Dialog#refresh
 * @param {Object} msg - incoming re-INVITE request message
 */
/**
 * an <code>info</code> event is triggered when the far end sends an INFO message.
 * When an application adds a handler for this event it must generate
 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
 * When no handler is found for this event a 200 OK will be automatically generated.
 * @event Dialog#info
 * @param {Object} req - drachtio request object
 * @param {Object} res - drachtio response object
 */
/**
 * a <code>notify</code> event is triggered when the far end sends a NOTIFY message.
 * When an application adds a handler for this event it must generate
 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
 * When no handler is found for this event a 200 OK will be automatically generated.
 * @event Dialog#notify
 * @param {Object} req - drachtio request object
 * @param {Object} res - drachtio response object
 */
/**
 * an <code>update</code> event is triggered when the far end sends an UPDATE message.
 * When an application adds a handler for this event it must generate
 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
 * When no handler is found for this event a 200 OK will be automatically generated.
 * @event Dialog#update
 * @param {Object} req - drachtio request object
 * @param {Object} res - drachtio response object
 */
/**
 * a <code>refer</code> event is triggered when the far end sends a REFER message.
 * When an application adds a handler for this event it must generate
 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
 * When no handler is found for this event a 200 OK will be automatically generated.
 * @event Dialog#refer
 * @param {Object} req - drachtio request object
 * @param {Object} res - drachtio response object
 */