gateway/Shard.js

"use strict";

// utils & models
const Store = require('../utils/Store');

// events
const ChannelCreate = require('./events/channelcreate');
const ChannelUpdate = require('./events/channelupdate');
const ChannelDelete = require('./events/channeldelete');
const Ready = require('./events/ready');
const GuildCreate = require('./events/guildcreate');
const GuildDelete = require('./events/guilddelete');
const GuildMembersChunk = require('./events/guildmemberschunk');
const PresenceUpdate = require('./events/presenceupdate');
const MessageCreate = require('./events/messagecreate');
const GuildMemberAdd = require('./events/guildmemberadd');
const GuildMemberRemove = require('./events/guildmemberremove');
const GuildMemberUpdate = require('./events/guildmemberupdate');
const RoleCreate = require('./events/rolecreate');
const RoleDelete = require('./events/roledelete');
const RoleUpdate = require('./events/roleupdate');

let Websocket;

try {
  Websocket = require('uws');
} catch(e) {
  Websocket = require('ws');
};

/**
 * @class Represents a Discord Shard
 * @prop {String} id The id of the Shard
 * @prop {Store} guilds A store of guilds of each Shard
 * @prop {Number} ms The uptime of a shard in ms
 * @prop {Number} latency The API Latency of the Shard in ms
 */

class Shard {
  constructor(client, id) {
    this.client = client;
    this.id = typeof id === 'number' ? id.toString() : id;
    this.guilds = new Store();

    // non-enumerable Properties
    Object.defineProperty(this, 'status', { value: 'offline', writable: true });
    Object.defineProperty(this, 'guildLength', { value: 0, writable: true });
    Object.defineProperty(this, 'startTime', { value: 0, writable: true });
    Object.defineProperty(this, 'totalMemberCount', { value: 0, writable: true });
    Object.defineProperty(this, 'totalMemberCountOfGuildMemberChunk', { value: 0, writable: true });
    Object.defineProperty(this, 'fromReconnect', { value: false, writable: true });
  }

  get uptime() {
    return this.startTime ? Date.now() - this.startTime : 0;
  }
  
  /**
   * Disconnect/Reconnects a shard
   * @param {Boolean} [reconnect=false] Whether or not to reconnect
   * @return {Shard}
   */

  disconnect(reconnect = false) {
    if (!this.ws) return;

    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }

    try {
      if (reconnect) {
        this.client.emit('SHARD_RECONNECT', { id: this.id });
        this.status = 'reconnecting';
        this.ws.terminate();
        this.ws = null;
        setTimeout(() => {
          this.connect(true);
        }, 5000);
      } else {
        this.ws.close(1000);
      }
    } catch(error) {
      this.client.emit('error', error);
    }
    
    return this;
  }

  /**
   * Sends an activity to the shard
   * @param {Object} [options] The options for the Activity
   * @param {Number} [options.since=null] Unix time (in milliseconds) of when the client went idle, or null if the client is not idle
   * @param {Object} [options.game=null] The user's new activity
   * @param {String} [options.game.name=null] The activity's name
   * @param {Number} [options.game.type=0] The [activity's type](https://discordapp.com/developers/docs/topics/gateway#activity-object-activity-types)
   * @param {String} [options.game.url=null] The url of the activity ( Only for game type 1 )
   * @param {String} [options.status='online'] The new status of the client
   * @param {Boolean} [options.afk=false] Whether or not the client is afk
   * @returns {Object} The options for the Activity
   */

  setActivity(options = {}) {
    this.send({
      op: 3,
      d: {
        game: options.game || this.client.presence.game,
        since: options.sicne || this.client.presence.since,
        afk: Boolean(options.afk) || this.client.presence.afk,
        status: options.status || this.client.presence.status,
      }
    });

    return options;
  }

  /**
   * Setup some properties for the Shard
   * @returns {Shard}
   */

  setup() {
    let data = {
      latency: 0,
      seq: null,
      sessionID: null,
      heartbeatInterval: null,
      ws: null
    };

    for (let i of Object.entries(data)) {
      this[i[0]] = i[1];
    };

    return this;
  }

  /**
   * Connects the shard
   * @returns {Boolean} Whether the connect was from a reconnect
   */

  connect(reconnected = false) {
    this.initiate();

    if (reconnected) this.fromReconnect = true;

    /**
     * Emitted once a Shard is loading, inner data is a partial Shard Data
     * @event Client.SHARD_LOADING
     * @prop {String} id The id of the shard
     */

    this.client.emit('SHARD_LOADING', { id: this.id });

    // Set status to loading
    this.status = 'loading';

    // Debugger
    this.client.emit('debug', { shard: this.id, message: 'Initiating Shard, connecting...' });

    return reconnected;
  }

  /**
   * Initiate the shard
   * @returns {Shard}
   */

  initiate() {
    this.ws = new Websocket(`${this.client.gatewayURL}/?v=6&encoding=json`);

    this.ws.on('message', (event) => {
      let packet = JSON.parse(event);

      this.listen_message(packet);
    });

    this.ws.onclose = (event) => {
      let err = !event.code || event.code === 1000 ? null : new Error(event.code + ": " + event.reason);
      let reconnect = true;
      if (event.code) {
        this.client.emit("debug", { shard: this.id, message: `${event.code === 1000 ? "Clean" : "Unclean"} WS close: ${event.code}: ${event.reason}` });
        if (event.code === 4001) {
          err = new Error("Gateway received invalid OP code");
        } else if (event.code === 4002) {
          err = new Error("Gateway received invalid message");
        } else if (event.code === 4003) {
          err = new Error("Not authenticated");
        } else if (event.code === 4004) {
          err = new Error("Authentication failed");
          reconnect = false;
          this.client.emit("error", new Error(`Invalid token: ${this.client.token}`));
        } else if (event.code === 4005) {
          err = new Error("Already authenticated");
        } else if (event.code === 4006 || event.code === 4009) {
          err = new Error("Invalid session");
          this.sessionID = null;
        } else if (event.code === 4007) {
          err = new Error("Invalid sequence number: " + this.seq);
          this.seq = 0;
        } else if (event.code === 4008) {
          err = new Error("Gateway connection was ratelimited");
        } else if (event.code === 4010) {
          err = new Error("Invalid shard key");
          reconnect = false;
        } else if (event.code === 4011) {
          err = new Error("Shard has too many guilds (>2500)");
          reconnect = false;
        } else if (event.code === 1006) {
          err = new Error("Connection reset by peer");
        } else if (!event.wasClean && event.reason) {
          err = new Error(event.code + ": " + event.reason);
        }

        this.client.emit('debug', { shard: this.id, message: err.message });
      } else {
        this.client.emit("debug", { shard: this.id, message: "WS close: unknown code: " + event.reason });
      }
      this.disconnect(reconnect);

      if (this.client.connectedShards.has(this.id.toString()))
        this.client.connectedShards.delete(this.id.toString());

      this.status = 'closed';
      this.disconnect(false)

      if (this.client.connectedShards.size === 0)
        /**
         * Emitted once all shards disconnect
         * @event Client.SHARD_DISCONNECT_ALL
         * @prop {Object} data The data of the event
         * @prop {String} data.message The message
         */
        this.client.emit('SHARD_DISCONNECT_ALL', { message: 'All Shards has been disconnected!' });
      
      /**
       * Emitted once a Shard Disconnects
       * @event Client.SHARD_DISCONNECT
       * @prop {Shard} shard Partial Shard data
       * @prop {String} shard.id Id of the shard
       * @prop {String} shard.description Description of the disconnect
       * @prop {String} shard.reason Websocket reason for the disconnect
       */
      this.client.emit('SHARD_DISCONNECT', ({ id: this.id, description: `Shard Disconnected with Close Code: ${event.code}`, reason: event.reason || 'No reason given' }));
    };

    return this;
  }

  /**
   * Listens for messages from the Websocket
   * @param {Object} packet The packet received from discord
   * @returns {Object<Packet>}
   */

  listen_message(packet) {
    switch(packet.op) {
      case 10:
        // Debugger
        this.client.emit('debug', { shard: this.id, message: 'Received Opcode 10 ( Hello ) from Discord!' });

        // Start heartbeating
        this.start_heartbeat(packet.d.heartbeat_interval);
        break;

      case 0:
        this.seq = packet.s;

        // Handle events
        this.listen_event(packet);
        break;

      case 11:
        this.lastHeartbeatReceived = new Date().getTime();

        // Get the latency
        this.latency = this.lastHeartbeatReceived - this.lastHeartbeatSent;
        break;

      case 9:
        if (!packet.d) {
          // Debugger #1
          this.client.emit('debug', { shard: this.id, message: 'Received Opcode 9 ( Invalid Session ). Will re-login.' });
          this.identify();
        } else {
          // Debugger #2
          this.client.emit('debug', { shard: this.id, message: 'Received Opcode 9 ( Invalid Session ). Will resume.' });
          this.disconnect();

          this.ws = new Websocket(`${this.client.gatewayURL}?v=6&encoding=json`);
          this.ws.on('open', () => this.resume());
        }
        break;

      case 7:
        this.disconnect(true);
        break;
    };

    return packet;
  }

  /**
   * Listens for Discord events from the Websocket
   * @param {Object} packet The packet received from discord
   * @returns {Object<Packet>}
   */

  listen_event(packet) {
    switch(packet.t) {
      case 'READY':
        new Ready().emit(this, packet);
        break;

      case 'GUILD_CREATE':
        new GuildCreate().emit(this, packet);
        break;

      case 'GUILD_MEMBERS_CHUNK':
        new GuildMembersChunk().emit(this, packet);
        break;

      case 'PRESENCE_UPDATE':
        new PresenceUpdate().emit(this, packet);
        break;

      case 'MESSAGE_CREATE':
        new MessageCreate().emit(this, packet);
        break;

      case 'GUILD_MEMBER_ADD':
        new GuildMemberAdd().emit(this, packet);
        break;

      case 'GUILD_MEMBER_REMOVE':
        new GuildMemberRemove().emit(this, packet);
        break;
      
      case 'GUILD_MEMBER_UPDATE':
        (async() => {
          new GuildMemberUpdate().emit(this, packet);
        })();
        break;

      case 'GUILD_ROLE_CREATE':
        new RoleCreate().emit(this, packet);
        break;

      case 'GUILD_ROLE_DELETE':
        new RoleDelete().emit(this, packet);
        break;

      case 'GUILD_ROLE_UPDATE':
        new RoleUpdate().emit(this, packet);
        break;

      case 'CHANNEL_CREATE':
        new ChannelCreate().emit(this, packet);
        break;

      case 'CHANNEL_UPDATE':
        new ChannelUpdate().emit(this, packet);
        break;

      case 'CHANNEL_DELETE':
        new ChannelDelete().emit(this, packet);
        break;

      case 'GUILD_DELETE':  
        new GuildDelete().emit(this, packet);
        break;
    }
  }

  start_heartbeat(interval) {
    // Send the first heartbeat
    this.send({ op: 1, d: { seq: this.seq } });

    // Get the time the last heartbeat was sent in ms
    this.lastHeartbeatSent = new Date().getTime();

    // Debugger #1
    this.client.emit('debug', { shard: this.id, message: 'Sent the first Heartbeat to Discord! Starting Interval...' });

    // Send an identify payload
    this.identify();

    // Debugger #2
    this.client.emit('debug', { shard: this.id, message: `Sent an Opcode 2 ( Identify ) to Discord! With token: ${this.client.token}` });

    // Let's create an interval here for sending heartbeats
    this.heartbeatInterval = setInterval(() => {
      this.send({ op: 1, d: { seq: this.seq } });

      // Get the time the last heartbeat was sent in ms
      this.lastHeartbeatSent = new Date().getTime();

      // Debugger #3
      this.client.emit('debug', { shard: this.id, message: 'Sent another Heartbeat to Discord!' });
    }, interval);

    return this;
  }

  /**
   * Sends data to the websocket
   * @param {Any} data The data to send
   * @returns {Data}
   */

  send(data) {
    this.ws.send(typeof data === 'object' ? JSON.stringify(data) : data);
    return data;
  }

  /**
   * Sends an Opcode 8 ( Request Guild Members ) to discord
   */

  fetchAllMembers(guilds) {
    return this.send({ op: 8, d: {
        guild_id: guilds,
        query: "",
        limit: 0
      }
    });
  }

  /**
   * Sends the identify payload to discord for logging in
   */

  identify() {
    return this.send({
      op: 2,
      d: {
        token: this.client.token,
        properties: {
          $os: process.platform,
          $browser: 'jcord',
          $device: 'jcord'
        },
        large_threshold: this.client.largeThreshold,
        shard: [typeof this.id === 'number' ? this.id : parseInt(this.id), this.client.shardCount]
      }
    });
  }

  /**
   * Sends an Opcode 6 ( Resume ) to Discord
   */

  resume() {
    return this.send({
      op: 6,
      d: {
        token: this.client.token,
        session_id: this.sessionID,
        seq: this.seq
      }
    })
  }
};

module.exports = Shard;