class Vox::Gateway::Client

A client for receiving and writing data from the gateway. The client uses an emitter pattern for emitting and registering events. @example

client.on(:MESSAGE_CREATE) do |payload|
  puts "Hello!" if payload[:content] == "hello"
end

Constants

DEFAULT_PROPERTIES

@!visibility private The default properties for the identify packet

GATEWAY_VERSION

The gateway version to request.

LOGGER

@!visibility private The logger for Vox::Gateway::Client

OPCODES

@!visibility private A hash of opcodes => op_names, as well as op_names => opcodes.

Session

Class that holds information about a session.

Attributes

session[R]

@return [Session] The connection's session information.

Public Class Methods

new(url:, token:, port: nil, encoding: :json, compress: true, shard: [0, 1], properties: DEFAULT_PROPERTIES, large_threshold: nil, presence: nil, intents: nil) click to toggle source

@param url [String] The url to use when connecting to the websocket. This can be

retrieved from the API with {HTTP::Routes::Gateway#get_gateway_bot}.

@param token [String] The token to use for authorization. @param port [Integer] The port to use when connecting. If `nil`, it will be inferred

from the URL scheme (80 for `ws`, and 443 for `wss`).

@param encoding [:json] This only accepts `json` currently, but may support `:etf` in future versions. @param compress [true, false] Whether to use `zlib-stream` compression. @param shard [Array<Integer>] An array in the format `[ShardNumber, TotalShards]`. @param large_threshold [Integer] @param presence [Object] @param intents [Integer]

# File lib/vox/gateway/client.rb, line 62
def initialize(url:, token:, port: nil, encoding: :json, compress: true, shard: [0, 1],
               properties: DEFAULT_PROPERTIES, large_threshold: nil, presence: nil, intents: nil)
  uri = create_gateway_uri(url, port: port, encoding: encoding, compress: compress)

  @encoding = encoding
  raise ArgumentError, 'Invalid gateway encoding' unless %i[json etf].include? @encoding

  if @encoding == :etf
    begin
      require 'vox/etf'
    rescue LoadError
      Logging.logger[self].error { 'ETF parsing lib not found. Please install vox-etf to use ETF encoding.' }
      raise Vox::Error.new('ETF lib not found')
    end
  end

  @websocket = WebSocket.new(uri.to_s, port: uri.port, compression: compress)
  @identify_opts = {
    token: token, properties: properties, shard: shard,
    large_threshold: large_threshold, presence: presence, intents: intents
  }.compact
  @session = Session.new
  @should_reconnect = Queue.new
  setup_handlers
end

Public Instance Methods

close(reason = nil, code = 1000, reconnect: false) click to toggle source

Close the websocket. @param code [Integer] The close code. @param reason [String] The reason for closing.

# File lib/vox/gateway/client.rb, line 109
def close(reason = nil, code = 1000, reconnect: false)
  @ws_thread.kill unless reconnect
  @websocket.close(reason, code)
  @websocket.thread.join unless reconnect
end
connect(async: false) click to toggle source

Connect the websocket to the gateway.

# File lib/vox/gateway/client.rb, line 95
def connect(async: false)
  @ws_thread = Thread.new do
    loop do
      @websocket.connect
      @websocket.thread.join
      break unless @should_reconnect.shift
    end
  end
  async ? @ws_thread : @ws_thread.join
end
presence_update(status:, afk: false, game: nil, since: nil) click to toggle source

Update the bot's status. @param status [String] The user's new status. @param afk [true, false] Whether or not the client is AFK. @param game [Hash<Symbol, Object>, nil] An [activity object](discord.com/developers/docs/topics/gateway#activity-object). @param since [Integer, nil] Unix time (in milliseconds) of when the client went idle.

# File lib/vox/gateway/client.rb, line 163
def presence_update(status:, afk: false, game: nil, since: nil)
  opts = { status: status, afk: afk, game: game, since: since }.compact
  send_packet(OPCODES[:PRESENCE_UPDATE], opts)
end
request_guild_members(guild_id, query: nil, limit: 0, presences: nil, user_ids: nil, nonce: nil) click to toggle source

Request a guild member chunk, used to build a member cache. @param guild_id [String, Integer] @param query @param limit [Integer] @param presences @param user_ids [Array<String, Integer>] @param nonce [String, Integer]

# File lib/vox/gateway/client.rb, line 134
def request_guild_members(guild_id, query: nil, limit: 0, presences: nil,
                          user_ids: nil, nonce: nil)
  opts = {
    guild_id: guild_id, query: query, limit: limit, presences: presences,
    user_ids: user_ids, nonce: nonce
  }.compact

  send_packet(OPCODES[:REQUEST_GUILD_MEMBERS], opts)
end
send_packet(op_code, data) click to toggle source

Send a packet with the correct encoding. Only supports JSON currently. @param op_code [Integer] @param data [Hash]

# File lib/vox/gateway/client.rb, line 118
def send_packet(op_code, data)
  LOGGER.debug { "Sending #{op_code.is_a?(Symbol) ? op_code : OPCODES[op_code]} #{data || 'nil'}" }
  if @encoding == :etf
    send_etf_packet(op_code, data)
  else
    send_json_packet(op_code, data)
  end
end
voice_state_update(guild_id, channel_id, self_mute: false, self_deaf: false) click to toggle source

Send a voice state update, used for establishing voice connections. @param guild_id [String, Integer] @param channel_id [String, Integer] @param self_mute [true, false] @param self_deaf [true, false]

# File lib/vox/gateway/client.rb, line 149
def voice_state_update(guild_id, channel_id, self_mute: false, self_deaf: false)
  opts = {
    guild_id: guild_id, channel_id: channel_id, self_mute: self_mute,
    self_deaf: self_deaf
  }.compact

  send_packet(OPCODES[:VOICE_STATE_UPDATE], opts)
end

Private Instance Methods

create_gateway_uri(url, port: nil, encoding: :json, compress: true) click to toggle source

Create a URI from a gateway url and options @param url [String] @param port [Integer] @param encoding [:json] @param compress [true, false] @return [URI::Generic]

# File lib/vox/gateway/client.rb, line 192
def create_gateway_uri(url, port: nil, encoding: :json, compress: true)
  compression = compress ? 'zlib-stream' : nil
  query = URI.encode_www_form(
    version: GATEWAY_VERSION, encoding: encoding, compress: compression
  )
  URI(url).tap do |u|
    u.query = query
    u.port = port
  end
end
handle_close(data) click to toggle source

Handle a close event from the websocket. @param data [Hash{:code => Integer, :reason => String}]

# File lib/vox/gateway/client.rb, line 351
def handle_close(data)
  LOGGER.warn { "Websocket closed (#{data[:code]} #{data[:reason]})" }
  @heartbeat_thread&.kill
  reconnect = true

  case data[:code]
  # Invalid seq when resuming, or session timed out
  when 4007, 4009
    LOGGER.error { 'Invalid session, reconnecting.' }
    @session = Session.new
  when 4003, 4004, 4011
    LOGGER.fatal { data[:reason] }
    reconnect = false
  else
    LOGGER.error { data[:reason] } if data[:reason]
  end

  @should_reconnect << reconnect
end
handle_dispatch(payload) click to toggle source

Handle a dispatch event, extracting the event name and emitting an event. @param payload [Hash<Symbol, Object>] The decoded payload's `data` field.

# File lib/vox/gateway/client.rb, line 301
def handle_dispatch(payload)
  LOGGER.debug { "Emitting #{payload[:t]}" }
  emit(payload[:t], payload[:d])
end
handle_etf_message(data) click to toggle source

Handle an ETF message, decoding it and emitting an event. @param data [String] The ETF data.

# File lib/vox/gateway/client.rb, line 275
def handle_etf_message(data)
  data = Vox::ETF.decode(data)
  LOGGER.debug { "Emitting #{OPCODES[data[:op]]}" } if OPCODES[data[:op]] != :DISPATCH

  @session.seq = data[:s] if data[:s]
  op = OPCODES[data[:op]]

  emit(op, data)
end
handle_heartbeat(_payload) click to toggle source

Fired if the gateway requests that we send a heartbeat. @param _payload [Object] The received payload, not used in this method.

# File lib/vox/gateway/client.rb, line 322
def handle_heartbeat(_payload)
  send_packet(OPCODES[:HEARTBEAT], @session.seq)
end
handle_heartbeat_ack(_payload) click to toggle source

Handle a heartbeat acknowledgement from the gateway. @param _payload [Object] The received payload, not used in this method.

# File lib/vox/gateway/client.rb, line 345
def handle_heartbeat_ack(_payload)
  @heartbeat_acked = true
end
handle_hello(payload) click to toggle source

Handle a hello event, beginning the heartbeat loop and identifying or resuming. @param payload [Hash<Symbol, Object>] The decoded payload.

# File lib/vox/gateway/client.rb, line 309
def handle_hello(payload)
  LOGGER.info { 'Connected' }
  @heartbeat_interval = payload[:d][:heartbeat_interval] / 1000
  @heartbeat_thread = Thread.new { heartbeat_loop }
  if @session.seq
    send_resume
  else
    send_identify
  end
end
handle_invalid_session(_payload) click to toggle source

@param _payload [Object] The received payload, not used in this method.

# File lib/vox/gateway/client.rb, line 333
def handle_invalid_session(_payload)
  @session.seq = nil
  send_identify
end
handle_json_message(json) click to toggle source

Handle a JSON message, decoding it and emitting an event. @param json [String] The JSON data.

# File lib/vox/gateway/client.rb, line 287
def handle_json_message(json)
  data = MultiJson.load(json, symbolize_keys: true)
  # Don't announce DISPATCH events since we log it on the same level
  # in the dispatch handler.
  LOGGER.debug { "Emitting #{OPCODES[data[:op]]}" } if OPCODES[data[:op]] != :DISPATCH

  @session.seq = data[:s] if data[:s]
  op = OPCODES[data[:op]]

  emit(op, data)
end
handle_message(data) click to toggle source

Handle a message from the websocket. @param data [String] The message data.

# File lib/vox/gateway/client.rb, line 265
def handle_message(data)
  if @encoding == :etf
    handle_etf_message(data)
  else
    handle_json_message(data)
  end
end
handle_ready(payload) click to toggle source

Set session information from the ready payload. @param payload [Object] The received ready payload.

# File lib/vox/gateway/client.rb, line 328
def handle_ready(payload)
  @session.id = payload[:session_id]
end
handle_reconnect(_payload) click to toggle source

@param _payload [Object] The received payload, not used in this method.

# File lib/vox/gateway/client.rb, line 339
def handle_reconnect(_payload)
  @websocket.close('Received reconnect', 4000)
end
heartbeat_loop() click to toggle source

A loop that handles sending and receiving heartbeats from the gateway.

# File lib/vox/gateway/client.rb, line 239
def heartbeat_loop
  loop do
    send_heartbeat
    sleep @heartbeat_interval
    next if @heartbeat_acked

    LOGGER.error { 'Heartbeat was not acked, reconnecting.' }
    @websocket.close
    break
  end
end
send_etf_packet(op_code, data) click to toggle source

Send an ETF packet. @param op_code [Integer] @param data [Hash]

# File lib/vox/gateway/client.rb, line 215
def send_etf_packet(op_code, data)
  payload = { op: op_code, d: data }
  @websocket.send_binary(Vox::ETF.encode(payload))
end
send_heartbeat() click to toggle source

Send a heartbeat.

# File lib/vox/gateway/client.rb, line 233
def send_heartbeat
  @heartbeat_acked = false
  send_packet(OPCODES[:HEARTBEAT], @session.seq)
end
send_identify() click to toggle source

Send an identify payload to discord, beginning a new session.

# File lib/vox/gateway/client.rb, line 221
def send_identify
  send_packet(OPCODES[:IDENTIFY], @identify_opts)
end
send_json_packet(op_code, data) click to toggle source

Send a JSON packet. @param op_code [Integer] @param data [Hash]

# File lib/vox/gateway/client.rb, line 206
def send_json_packet(op_code, data)
  payload = { op: op_code, d: data }

  @websocket.send_json(payload)
end
send_resume() click to toggle source

Send a resume payload to discord, attempting to resume an existing session.

# File lib/vox/gateway/client.rb, line 227
def send_resume
  send_packet(OPCODES[:RESUME],
              { token: @identify_opts[:token], session_id: @session.id, seq: @session.seq })
end
setup_handlers() click to toggle source

Add internal event handlers

# File lib/vox/gateway/client.rb, line 171
def setup_handlers
  # Discord will contact us with HELLO first, so we don't need to hook into READY
  @websocket.on(:message, &method(:handle_message))
  @websocket.on(:close, &method(:handle_close))

  # Setup payload handlers
  on(:DISPATCH, &method(:handle_dispatch))
  on(:HEARTBEAT, &method(:handle_heartbeat))
  on(:RECONNECT, &method(:handle_reconnect))
  on(:INVALID_SESSION, &method(:handle_invalid_session))
  on(:HELLO, &method(:handle_hello))
  on(:HEARTBEAT_ACK, &method(:handle_heartbeat_ack))
  on(:READY, &method(:handle_ready))
end