class MijDiscord::Core::Gateway

Constants

GATEWAY_VERSION
LARGE_THRESHOLD

Attributes

check_heartbeat_acks[RW]

Public Class Methods

new(bot, auth, shard_key = nil) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 113
def initialize(bot, auth, shard_key = nil)
  @bot, @auth, @shard_key = bot, auth, shard_key

  @ws_success = false
  @getc_mutex = Mutex.new

  @check_heartbeat_acks = true
end

Public Instance Methods

heartbeat() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 170
def heartbeat
  if check_heartbeat_acks
    unless @last_heartbeat_acked
      MijDiscord::LOGGER.warn('Gateway') { 'Heartbeat not acknowledged, attempting to reconnect' }

      @broken_pipe = true
      reconnect(true)
      return
    end

    @last_heartbeat_acked = false
  end

  send_heartbeat(@session&.sequence || 0)
end
kill() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 155
def kill
  @ws_thread&.kill
  nil
end
notify_ready() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 274
def notify_ready
  @ws_success = true
end
open?() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 160
def open?
  @handshake&.finished? && !@ws_closed
end
reconnect(try_resume = true) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 186
def reconnect(try_resume = true)
  @session&.suspend if try_resume

  @instant_reconnect = true
  @should_reconnect = true

  ws_close(false)
  nil
end
run_async() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 122
def run_async
  @ws_thread = Thread.new do
    Thread.current[:mij_discord] = 'websocket'

    @reconnect_delay = 1.0

    loop do
      ws_connect

      break unless @should_reconnect

      if @instant_reconnect
        @reconnect_delay = 1.0
        @instant_reconnect = false
      else
        sleep(@reconnect_delay)
        @reconnect_delay = [@reconnect_delay * 1.5, 120].min
      end
    end

    MijDiscord::LOGGER.info('Gateway') { 'Websocket loop has been terminated' }
  end

  sleep(0.2) until @ws_success
  MijDiscord::LOGGER.info('Gateway') { 'Connection established and confirmed' }
  nil
end
send_heartbeat(sequence) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 196
def send_heartbeat(sequence)
  send_packet(Opcodes::HEARTBEAT, sequence)
end
send_identify(token, properties, compress, large_threshold, shard_key) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 200
def send_identify(token, properties, compress, large_threshold, shard_key)
  data = {
    token: token,
    properties: properties,
    compress: compress,
    large_threshold: large_threshold,
  }

  data[:shard] = shard_key if shard_key

  send_packet(Opcodes::IDENTIFY, data)
end
send_packet(opcode, packet) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 259
def send_packet(opcode, packet)
  data = {
    op: opcode,
    d: packet,
  }

  ws_send(data.to_json, :text)
  nil
end
send_raw(data, type = :text) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 269
def send_raw(data, type = :text)
  ws_send(data, type)
  nil
end
send_request_guild_sync(guilds) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 255
def send_request_guild_sync(guilds)
  send_packet(Opcodes::GUILD_SYNC, guilds)
end
send_request_members(server_id, query = '', limit = 0) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 245
def send_request_members(server_id, query = '', limit = 0)
  data = {
    guild_id: server_id,
    query: query,
    limit: limit,
  }

  send_packet(Opcodes::REQUEST_MEMBERS, data)
end
send_resume(token, session_id, sequence) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 235
def send_resume(token, session_id, sequence)
  data = {
    token: token,
    session_id: session_id,
    seq: sequence,
  }

  send_packet(Opcodes::RESUME, data)
end
send_status_update(status, since, game, afk) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 213
def send_status_update(status, since, game, afk)
  data = {
    status: status,
    since: since,
    game: game,
    afk: afk,
  }

  send_packet(Opcodes::PRESENCE, data)
end
send_voice_state_update(server_id, channel_id, self_mute, self_deaf) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 224
def send_voice_state_update(server_id, channel_id, self_mute, self_deaf)
  data = {
    guild_id: server_id,
    channel_id: channel_id,
    self_mute: self_mute,
    self_deaf: self_deaf,
  }

  send_packet(Opcodes::VOICE_STATE, data)
end
stop(no_sync = false) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 164
def stop(no_sync = false)
  @should_reconnect = false
  ws_close(no_sync)
  nil
end
sync() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 150
def sync
  @ws_thread&.join
  nil
end

Private Instance Methods

get_gateway_url() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 341
def get_gateway_url
  response = API.gateway(@auth)
  raw_url = JSON.parse(response)['url']
  raw_url << '/' unless raw_url.end_with? '/'
  "#{raw_url}?encoding=json&v=#{GATEWAY_VERSION}"
end
handle_dispatch(packet) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 484
def handle_dispatch(packet)
  data, type = packet['d'], packet['t'].to_sym

  case type
    when :READY
      @session = Session.new(data['session_id'])

      MijDiscord::LOGGER.info('Gateway') { "Received READY packet (user: #{data['user']['id']})" }
      MijDiscord::LOGGER.info('Gateway') { "Using gateway protocol version #{data['v']}, requested #{GATEWAY_VERSION}" }
    when :RESUMED
      MijDiscord::LOGGER.info('Gateway') { 'Received session resume confirmation' }
      return
  end

  @bot.handle_dispatch(type, data)
end
handle_hello(packet) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 501
def handle_hello(packet)
  interval = packet['d']['heartbeat_interval'].to_f / 1000.0
  setup_heartbeat(interval)

  if @session&.should_resume?
    @session.resume
    send_resume_self
  else
    send_identify_self
  end
end
handle_message(msg) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 459
def handle_message(msg)
  msg = Zlib::Inflate.inflate(msg) if msg.byteslice(0) == 'x'

  packet = JSON.parse(msg)
  @session&.sequence = packet['s'] if packet['s']

  case (opc = packet['op'].to_i)
    when Opcodes::DISPATCH
      handle_dispatch(packet)
    when Opcodes::HELLO
      handle_hello(packet)
    when Opcodes::RECONNECT
      reconnect
    when Opcodes::INVALIDATE_SESSION
      @session&.invalidate
      send_identify_self
    when Opcodes::HEARTBEAT_ACK
      @last_heartbeat_acked = true if @check_heartbeat_acks
    when Opcodes::HEARTBEAT
      send_heartbeat(packet['s'])
    else
      MijDiscord::LOGGER.error('Gateway') { "Invalid opcode received: #{opc}" }
  end
end
obtain_socket(uri) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 321
def obtain_socket(uri)
  secure = %w[https wss].include?(uri.scheme)
  socket = TCPSocket.new(uri.host, uri.port || (secure ? 443 : 80))

  if secure
    ctx = OpenSSL::SSL::SSLContext.new
    ctx.ssl_version = 'SSLv23'
    ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification

    cert_store = OpenSSL::X509::Store.new
    cert_store.set_default_paths
    ctx.cert_store = cert_store

    socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
    socket.connect
  end

  socket
end
send_identify_self() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 280
def send_identify_self
  props = {
      '$os': RUBY_PLATFORM,
      '$browser': 'mij-discord',
      '$device': 'mij-discord',
      '$referrer': '',
      '$referring_domain': '',
  }

  send_identify(@auth, props, true, LARGE_THRESHOLD, @shard_key)
end
send_resume_self() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 292
def send_resume_self
  send_resume(@auth, @session.session_id, @session.sequence)
end
setup_heartbeat(interval) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 296
def setup_heartbeat(interval)
  @last_heartbeat_acked = true

  return if @heartbeat_thread

  @heartbeat_thread = Thread.new do
    Thread.current[:mij_discord] = 'heartbeat'

    loop do
      begin
        if @session&.suspended?
          sleep(1.0)
        else
          sleep(interval)
          @bot.handle_heartbeat
          heartbeat
        end
      rescue => exc
        MijDiscord::LOGGER.error('Gateway') { 'An error occurred during heartbeat' }
        MijDiscord::LOGGER.error('Gateway') { exc }
      end
    end
  end
end
ws_close(no_sync) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 440
def ws_close(no_sync)
  return if @ws_closed

  @session&.suspend

  ws_send(nil, :close) unless @broken_pipe

  if no_sync
    @ws_closed = true
  else
    @getc_mutex.synchronize { @ws_closed = true }
  end

  @socket&.close
  @socket = nil

  @bot.handle_dispatch(:DISCONNECT, nil)
end
ws_connect() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 348
def ws_connect
  url = get_gateway_url
  gateway_uri = URI.parse(url)

  @socket = obtain_socket(gateway_uri)
  @handshake = WebSocket::Handshake::Client.new(url: url)
  @handshake_done, @broken_pipe, @ws_closed = false, false, false

  ws_mainloop
rescue => exc
  MijDiscord::LOGGER.error('Gateway') { 'An error occurred during websocket connect' }
  MijDiscord::LOGGER.error('Gateway') { exc }
end
ws_mainloop() click to toggle source
# File lib/mij-discord/core/gateway.rb, line 362
def ws_mainloop
  @bot.handle_dispatch(:CONNECT, nil)

  @socket.write(@handshake.to_s)

  frame = WebSocket::Frame::Incoming::Client.new

  until @ws_closed
    begin
      unless @socket
        ws_close(false)
        MijDiscord::LOGGER.error('Gateway') { 'Socket object is nil in main websocket loop' }
      end

      recv_data = nil
      @getc_mutex.synchronize { recv_data = @socket&.getc }

      unless recv_data
        sleep(1.0)
        next
      end

      if @handshake_done
        frame << recv_data

        loop do
          msg = frame.next
          break unless msg

          if msg.respond_to?(:code) && msg.code
            MijDiscord::LOGGER.warn('Gateway') { 'Received websocket close frame' }
            MijDiscord::LOGGER.warn('Gateway') { "(code: #{msg.code}, info: #{msg.data})" }

            codes = [1000, 4004, 4010, 4011]
            if codes.include?(msg.code)
              ws_close(false)
            else
              MijDiscord::LOGGER.warn('Gateway') { 'Non-fatal code, attempting to reconnect' }
              reconnect(true)
            end

            break
          end

          handle_message(msg.data)
        end
      else
        @handshake << recv_data
        @handshake_done = true if @handshake.finished?
      end
    rescue Errno::ECONNRESET
      @broken_pipe = true
      reconnect(true)
      MijDiscord::LOGGER.warn('Gateway') { 'Connection reset by remote host, attempting to reconnect' }
    rescue => exc
      MijDiscord::LOGGER.error('Gateway') { 'An error occurred in main websocket loop' }
      MijDiscord::LOGGER.error('Gateway') { exc }
    end
  end
end
ws_send(data, type) click to toggle source
# File lib/mij-discord/core/gateway.rb, line 423
def ws_send(data, type)
  unless @handshake_done && !@ws_closed
    raise 'Tried to send something to the websocket while not being connected!'
  end

  frame = WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version)

  begin
    @socket.write frame.to_s
  rescue => e
    @broken_pipe = true
    ws_close(false)
    MijDiscord::LOGGER.error('Gateway') { 'An error occurred during websocket write' }
    MijDiscord::LOGGER.error('Gateway') { e }
  end
end