Source: session.js

const Emitter = require('./emitter');
const Promise = require('q');
const Constants = require('./constants');
const Utils = require('./utils');

/**
 * @classdesc Session class to start session with zello server and interact with it using <a href="">zello channel api</a>
 * @example
 var session = new ZCC.Session({
  serverUrl: 'wss://zellowork.io/ws/[yournetworkname]',
  username: [username],
  password: [password]
  channel: [channel],
  authToken: [authToken],
  maxConnectAttempts: 5,
  connectRetryTimeoutMs: 1000,
  autoSendAudio: true,
  noPersistentPlayer: false
);
 **/
class Session extends Emitter {
  /**
   * @param {object} options session options. Options can also include <code>player</code>, <code>decoder</code>, <code>recorder</code> and <code>encoder</code> overrides
   * @return {ZCC.Session} <code>ZCC.Session</code> instance
   **/
  constructor(options) {
    super();
    const library = Utils.getLoadedLibrary();
    Session.validateInitialOptions(options);
    this.options = Object.assign({}, library.Sdk.initOptions, {
      maxConnectAttempts: 5,
      connectRetryTimeoutMs: 1000,
      autoSendAudio: true,
      noPersistentPlayer: false
    }, options);
    this.callbacks = {};
    this.wsConnection = null;
    this.refreshToken = null;
    this.seq = 0;
    this.maxConnectAttempts = this.options.maxConnectAttempts;
    this.connectAttempts = this.maxConnectAttempts;
    this.connectRetryTimeoutMs = this.options.connectRetryTimeoutMs;
    this.selfDisconnect = false;
    this.incomingMessages = {};
    this.activeOutgoingMessage = null;
    this.activeOutgoingImage = null;
    this.wasOnline = false;
    this.reconnectTimeout = null;
    this.channelConfigurationError = false;
  }

  getSeq() {
    return ++this.seq;
  }

  static validateInitialOptions(initialOptions) {
    if (
      !initialOptions ||
      !initialOptions.serverUrl ||
      !initialOptions.channel ||
      (initialOptions.username && !initialOptions.password) ||
      (!initialOptions.authToken && !initialOptions.username)
    ) {
      throw new Error(Constants.ERROR_NOT_ENOUGH_PARAMS);
    }
    if (!initialOptions.serverUrl.match(/^wss?:\/\//i)) {
      throw new Error(Constants.ERROR_INVALID_SERVER_PROTOCOL);
    }
  }

  /**
   * Connects to zello server and starts new session
   *
   * @param {function} [userCallback] callback for connection event
   * @return {promise} promise that resolves once session successfully started and rejects on sessions start error
   * @example
// promise
session.connect()
  .then(function(result) {
    console.log('Session started: ', result)
  })
  .catch(function(err) {
    console.trace(err);
  });

 // callback
session.connect(function(err, result) {
  if (err) {
    console.trace(err);
    return;
  }
  console.log('session started:', result)
});
   ***/
  connect(userCallback = null) {
    return this.connectOrReconnect(userCallback);
  }

  clearExistingReconnectTimeout() {
    if (this.reconnectTimeout) {
      clearTimeout(this.reconnectTimeout);
    }
  }

  connectOrReconnect(userCallback = null, isReconnect = false) {
    let dfd = Promise.defer();
    if (!this.connectAttempts) {
      this.emit(
        this.channelConfigurationError ? Constants.EVENT_SESSION_DISCONNECT : Constants.EVENT_SESSION_FAIL_CONNECT
      );
      return dfd.reject('Failed to connect');
    }
    if (this.connectAttempts === this.maxConnectAttempts) {
      /**
       * The Session has opened a websocket connection to the server and ready to sign in
       * @event Session#session_start_connect
       */
      this.emit(Constants.EVENT_SESSION_START_CONNECT);
    }
    this.connectAttempts--;
    this.doConnect()
      .then(() => {
        return this.doLogon();
      })
      .then((result) => {
        if (typeof userCallback === 'function') {
          userCallback.apply(this, [null, result]);
        }
        /**
         * The Session has connected and signed in successfully
         * @event Session#session_connect
         */
        this.emit(Constants.EVENT_SESSION_CONNECT);
        dfd.resolve(result);
      })
      .catch((err) => {
        if (this.connectAttempts) {
          this.clearExistingReconnectTimeout();
          this.reconnectTimeout = setTimeout(() => {
            this.connectOrReconnect(userCallback, isReconnect);
          }, this.connectRetryTimeoutMs);
          return;
        }
        if (typeof userCallback === 'function') {
          userCallback.apply(this, [err]);
        }
        /**
         * The Session has failed to connect or sign in.
         * @event Session#session_fail_connect
         * @param {string} error Error description
         */
        /**
         * The Session was disconnected and failed to reconnect
         * @event Session#session_disconnect
         * @param {string} error Error description
         */
        this.emit(isReconnect ? Constants.EVENT_SESSION_DISCONNECT : Constants.EVENT_SESSION_FAIL_CONNECT, err);
      });
    return dfd.promise;
  }

  doConnect() {
    let dfd = Promise.defer();
    this.wsConnection = new WebSocket(this.options.serverUrl);
    this.wsConnection.binaryType = 'arraybuffer';

    this.wsConnection.addEventListener('open', () => {
      return dfd.resolve();
    });

    this.wsConnection.addEventListener('message', (event) => {
      this.wsMessageHandler(event.data);
    });

    this.wsConnection.addEventListener('error', (err) => {
      return dfd.reject(err);
    });

    this.wsConnection.addEventListener('close', (closeEvent) => {
      if (this.selfDisconnect) {
        this.selfDisconnect = false;
        return;
      }
      // disconnected from server after initial successful connection
      if (dfd.promise.inspect().state === 'fulfilled') {
        /**
         * The Session was disconnected and will try to reconnect
         * @event Session#session_connection_lost
         * @param {string} error Error description
         */
        this.emit(Constants.EVENT_SESSION_CONNECTION_LOST, closeEvent.reason);
        this.clearExistingReconnectTimeout();
        this.reconnectTimeout = setTimeout(() => {
          this.connectOrReconnect(null, true);
        }, this.connectRetryTimeoutMs);
      }
    });
    return dfd.promise;
  }

  doLogon(refreshToken = '') {
    let dfd = Promise.defer();
    let params = {
      'command': 'logon',
      'seq': this.getSeq(),
      'channel': this.options.channel
    };

    if (refreshToken) {
      params.refresh_token = refreshToken;
    } else {
      params.auth_token = this.options.authToken;
    }

    if (this.options.listenOnly) {
      params.listen_only = true;
    }

    if (this.options.username) {
      params.username = this.options.username;
      params.password = this.options.password;
    }

    let callback = (err, data) => {
      if (err) {
        dfd.reject(err);
        return;
      }
      dfd.resolve(data);
    };
    this.sendCommand(params, callback);
    return dfd.promise;
  }

  /**
   * Closes session and disconnects from zello server. To start session again you need to call <code>session.connect</code>
   */
  disconnect() {
    this.selfDisconnect = true;
    this.wsConnection.close();
  }

  wsBinaryDataHandler(data) {
    let parsedData = Utils.parseIncomingBinaryMessage(data);
    switch (parsedData.messageType) {
      case Constants.MESSAGE_TYPE_AUDIO:
        /**
         * The Session is receiving incoming voice message packet (with encoded audio)
         * @event Session#incoming_voice_data
         * @param {Object} incomingVoicePacket voice message packet object
         * @property {Uint8Array} messageData encoded (opus) data
         * @property {Number} messageId incoming message id
         * @property {Number} packetId incoming packet id
         */
        this.emit(Constants.EVENT_INCOMING_VOICE_DATA, parsedData);
        break;
      case Constants.MESSAGE_TYPE_IMAGE:
        this.emit(Constants.EVENT_INCOMING_IMAGE_DATA, parsedData);
        break;

    }
  }

  jsonDataHandler(jsonData) {
    if (jsonData && jsonData.seq) {
      this.handleCallbacks(jsonData);
    }
    if (jsonData.refresh_token) {
      this.refreshToken = jsonData.refresh_token;
    }
    const library = Utils.getLoadedLibrary();
    switch (jsonData.command) {
      case 'on_error':
        let error = Constants.ERROR_TYPE_UNKNOWN_SERVER_ERROR;
        if (jsonData.error) {
          error = jsonData.error;
        }
        /**
         * The Session received error message from server
         * @event Session#error
         * @param {string} error Error description
         */
        this.emit(Constants.EVENT_ERROR, error);
        break;
      case 'on_channel_status':
        /**
         * The Session is receiving channel status update
         * @event Session#status
         * @param {JSON} status JSON object
         * @property {String} channel channel name
         * @property {String} status new channel status
         * @property {Number} users_online number of online users
         */
        if (!this.wasOnline) {
          switch (jsonData.status) {
            case Constants.SN_STATUS_ONLINE:
              this.wasOnline = true;
              this.connectAttempts = this.maxConnectAttempts;
              break;
            case Constants.SN_STATUS_OFFLINE:
              if (jsonData.error && jsonData.error_type === Constants.ERROR_TYPE_CONFIGURATION) {
                this.channelConfigurationError = true;
              }
              break;
          }
        }
        this.emit(Constants.EVENT_STATUS, jsonData);
        break;
      case 'on_stream_start':
        const incomingMessage = new library.IncomingMessage(jsonData, this);
        this.incomingMessages[jsonData.stream_id] = incomingMessage;
        /**
         * Incoming voice message is about to start.
         * @event Session#incoming_voice_will_start
         * @param {ZCC.IncomingMessage} incomingMessage message instance
         */
        this.emit(Constants.EVENT_INCOMING_VOICE_WILL_START, incomingMessage);
        break;
      case 'on_stream_stop':
        /**
         * Incoming voice message stopped
         * @event Session#incoming_voice_did_stop
         * @param {ZCC.IncomingMessage} incomingMessage incoming message instance
         */
        this.emit(Constants.EVENT_INCOMING_VOICE_DID_STOP, this.incomingMessages[jsonData.stream_id]);
        break;
      case 'on_text_message':
        /**
         * Incoming channel text message
         * @event Session#incoming_text_message
         * @param json textMessage textMessage JSON
         */
        this.emit(Constants.EVENT_INCOMING_TEXT_MESSAGE, jsonData);
        break;
      case 'on_location':
        /**
         * Incoming location coordinates
         * @event Session#incoming_location
         * @param json location location data JSON
         */
        this.emit(Constants.EVENT_INCOMING_LOCATION, jsonData);
        break;
      case 'on_image':
        /**
         * Incoming image JSON metadata
         * @event Session#incoming_image
         * @param {ZCC.IncomingImage} IncomingImage incoming image instance
         */
        const incomingImage = new library.IncomingImage(jsonData, this);
        this.emit(Constants.EVENT_INCOMING_IMAGE, incomingImage);
    }
  }

  wsMessageHandler(data) {
    let jsonData = null;
    try {
      jsonData = JSON.parse(data);
    } catch (e) { }

    if (!jsonData) {
      return this.wsBinaryDataHandler(data);
    }
    return this.jsonDataHandler(jsonData);
  }

  handleCallbacks(jsonData) {
    let error = jsonData.error ? jsonData.error : null;
    let callback = this.callbacks[jsonData.seq];
    if (typeof  callback !== 'function') {
      return;
    }
    callback.apply(this, [error, jsonData]);
    delete this.callbacks[jsonData.seq];
  }

  sendCommand(params, callback = null) {
    if (params.seq && callback) {
      this.callbacks[params.seq] = callback;
    }
    this.wsConnection.send(JSON.stringify(params));
  }

  sendBinary(data) {
    this.wsConnection.send(data);
  }

  startStream(options = {}, userCallback = null) {
    return this.sendCommandWithCallback('start_stream', options, userCallback);
  }

  stopStream(options = {}, userCallback = null) {
    return this.sendCommandWithCallback('stop_stream', options, userCallback);
  }

  /**
   * Starts a voice message by creating OutgoingMessage instance
   *
   * @param {object} options options for outgoing messages.
   * Options can also include <code>recorder</code> and <code>encoder</code> overrides.
   *
   * @return {ZCC.OutgoingMessage} OutgoingMessage object
   * @example
   *
// use default recorder and encoder
var outgoingMessage = session.startVoiceMessage();

// use custom recorder
var outgoingMessage = session.startVoiceMessage({
  recorder: CustomRecorder
});

// use custom recorder and encoder
var outgoingMessage = session.startVoiceMessage({
  recorder: CustomRecorder,
  encoder: CustomEncoder
});
 **/
  startVoiceMessage(options = {}) {
    const library = Utils.getLoadedLibrary();
    this.activeOutgoingMessage = new library.OutgoingMessage(this, options);

    this.activeOutgoingMessage.on(Constants.EVENT_DATA_ENCODED, (data) => {
      if (!this.activeOutgoingMessage.options.autoSendAudio) {
        return;
      }
      this.sendBinary(data);
    });

    return this.activeOutgoingMessage;
  }

  onIncomingVoiceDidStart(incomingMessage) {
    /**
     * Incoming voice message did start (first packet received)
     *
     * @event Session#incoming_voice_did_start
     * @param {ZCC.IncomingMessage} incoming message instance
     */
    this.emit(Constants.EVENT_INCOMING_VOICE_DID_START, incomingMessage);
  }

  onIncomingVoiceDecoded(pcmData, incomingMessage) {
    /**
     * Incoming voice message packet decoded
     * @event Session#incoming_voice_data_decoded
     * @param {Float32Array} pcmData decoded pcm packet
     * @param {ZCC.IncomingMessage} incoming message instance
     */
    this.emit(Constants.EVENT_INCOMING_VOICE_DATA_DECODED, pcmData, incomingMessage);
  }

  /**
   * Starts sending an image message by creating OutgoingImage instance
   *
   * @param {object} options options for outgoing image.
   * @property {String} for optional username to send this image to
   * @property {Boolean} preview set it to false to automatically send an image without previewing.
   *                              if set to true (default) you will need to call OutgoingImage.send() to send an image
   * @property {File} File object (optional) if provided this file is send as an image with a source 'library'
   *
   * @return {ZCC.OutgoingImage} OutgoingImage object
   * @example
   *
   var outgoingImage = session.sendImage({
    preview: false,
    for: 'username'
   });
   **/
  sendImage(options = {}) {
    const library = Utils.getLoadedLibrary();
    this.activeOutgoingImage = new library.OutgoingImage(this, options);
    return this.activeOutgoingImage;
  }

  /**
   * Sends a text message
   *
   * @param {object} options options for outgoing text message.
   * @property {String} for optional username to send this text message to
   * @property {String} text message text
   *
   * @param {function} [userCallback] callback that is fired on message being send or failed to be sent
   * @return {promise} promise that resolves once session successfully send a text message and rejects if
   *                   text message sending failed
   * @example
   *
   session.sendTextMessage({
    for: 'username',
    text: 'Hello Zello!'
   });
   **/
  sendTextMessage(options = {}, userCallback = null) {
    return this.sendCommandWithCallback('send_text_message', options, userCallback);
  }

  sendLocation(options = {}, userCallback = null) {
    return this.sendCommandWithCallback('send_location', options, userCallback)
  }

  sendCommandWithCallback(command, options, userCallback = null) {
    options.seq = this.getSeq();
    options.command = command;
    let dfd = Promise.defer();
    let callback = (err, data) => {
      if (err) {
        if (typeof userCallback === 'function') {
          userCallback.apply(this, [err]);
        }
        dfd.reject(err);
        return;
      }
      if (typeof userCallback === 'function') {
        userCallback.apply(this, [null, data]);
      }
      dfd.resolve(data);
    };
    this.sendCommand(options, callback);
    return dfd.promise;
  }

}

module.exports = Session;