vox 0.2.2 → 0.2.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -1
- data/Rakefile +7 -0
- data/examples/gateway_example.rb +18 -0
- data/lib/vox.rb +27 -0
- data/lib/vox/gateway/client.rb +376 -0
- data/lib/vox/gateway/websocket.rb +180 -0
- data/lib/vox/http/client.rb +1 -8
- data/lib/vox/http/routes.rb +2 -1
- data/lib/vox/http/routes/gateway.rb +35 -0
- data/lib/vox/http/util.rb +0 -2
- data/lib/vox/version.rb +2 -2
- data/vox.gemspec +3 -0
- metadata +49 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff24b453b9f1e77745b7cf69fc4e37b2c8b41ea878fd25bb4d90a1ddff53cb84
|
4
|
+
data.tar.gz: 7eb6a27c3b74b29659689cee45eec1b5754262893103b6e4e1feac27899128f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 49c6cd9c4c7b512a9937b918facf227131ee5bf473bc8b69b5098314abd42796eaa16a2502d8e0255dfecc85c0a362e479d3c8642c1edac1910ab0a0bcf834b8
|
7
|
+
data.tar.gz: 2b1c08120d3599f5985d99c0efb0d35658ebd23ca163a07ad33e15e7a9165e9ab842bcd4f704c4a82823af97db0f28ad471c2a6ce77a038eaa58a4f61f0ec976
|
data/CHANGELOG.md
CHANGED
@@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file.
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
6
|
|
7
|
-
## [
|
7
|
+
## [0.2.2] - 2020-9-13
|
8
|
+
### Added
|
9
|
+
- Vox.setup_default_logger
|
10
|
+
- Gateway component
|
11
|
+
- Gateway routes (get_gateway, get_gateway_bot)
|
8
12
|
|
9
13
|
## [0.2.1] - 2020-8-29
|
10
14
|
### Added
|
data/Rakefile
CHANGED
@@ -3,6 +3,8 @@
|
|
3
3
|
require 'bundler/gem_tasks'
|
4
4
|
require 'rspec/core/rake_task'
|
5
5
|
require 'rubocop/rake_task'
|
6
|
+
require 'yard'
|
7
|
+
require 'yard/rake/yardoc_task'
|
6
8
|
|
7
9
|
RSpec::Core::RakeTask.new(:spec) do |t|
|
8
10
|
t.rspec_opts = '--format progress'
|
@@ -11,4 +13,9 @@ end
|
|
11
13
|
|
12
14
|
RuboCop::RakeTask.new(:rubocop)
|
13
15
|
|
16
|
+
YARD::Rake::YardocTask.new(:yard)
|
17
|
+
|
18
|
+
# Run all tests
|
19
|
+
task test: %i[rubocop yard spec]
|
20
|
+
|
14
21
|
task default: :spec
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'vox/http/client'
|
4
|
+
require 'vox/gateway/client'
|
5
|
+
|
6
|
+
Vox.setup_default_logger
|
7
|
+
|
8
|
+
token = ENV['VOX_TOKEN']
|
9
|
+
rest = Vox::HTTP::Client.new(token)
|
10
|
+
gateway = Vox::Gateway::Client.new(url: rest.get_gateway[:url], token: token)
|
11
|
+
|
12
|
+
gateway.on(:MESSAGE_CREATE) do |data|
|
13
|
+
rest.create_message(data[:channel_id], content: 'pong') if data[:content] == 'vox.ping'
|
14
|
+
end
|
15
|
+
|
16
|
+
Signal.trap('INT') { gateway.close('Disconnecting') }
|
17
|
+
|
18
|
+
gateway.connect
|
data/lib/vox.rb
CHANGED
@@ -4,6 +4,33 @@ require 'vox/version'
|
|
4
4
|
|
5
5
|
# Parent module containing all component pieces
|
6
6
|
module Vox
|
7
|
+
# Setup default appenders, log level, and formatting scheme.
|
8
|
+
# @param root_level [Symbol] The default logging level for all `Vox` loggers.
|
9
|
+
# @param rules [Hash<Class, Symbol>] Custom levels for each desired class.
|
10
|
+
# @example
|
11
|
+
# Vox.setup_default_logger(root_level: :warn, Vox::HTTP: :info, Vox::Gateway: :info)
|
12
|
+
def self.setup_default_logger(root_level: :info, **rules)
|
13
|
+
Logging.logger[Vox].level = root_level
|
14
|
+
|
15
|
+
rules.each do |log, level|
|
16
|
+
Logging.logger[log].level = level
|
17
|
+
end
|
18
|
+
|
19
|
+
Logging.color_scheme('vox_default',
|
20
|
+
levels: {
|
21
|
+
debug: :magenta,
|
22
|
+
info: :green,
|
23
|
+
warn: :yellow,
|
24
|
+
error: :red,
|
25
|
+
fatal: %i[white on_red]
|
26
|
+
},
|
27
|
+
date: :blue,
|
28
|
+
logger: :cyan)
|
29
|
+
|
30
|
+
Logging.logger[Vox].add_appenders(
|
31
|
+
Logging.appenders.stdout(layout: Logging.layouts.pattern(color_scheme: 'vox_default'))
|
32
|
+
)
|
33
|
+
end
|
7
34
|
# Catch all error for all Vox error subclasses
|
8
35
|
class Error < StandardError; end
|
9
36
|
# Your code goes here...
|
@@ -0,0 +1,376 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'vox/gateway/websocket'
|
4
|
+
require 'logging'
|
5
|
+
|
6
|
+
module Vox
|
7
|
+
module Gateway
|
8
|
+
# A client for receiving and writing data from the gateway.
|
9
|
+
# The client uses an emitter pattern for emitting and registering events.
|
10
|
+
# @example
|
11
|
+
# client.on(:MESSAGE_CREATE) do |payload|
|
12
|
+
# puts "Hello!" if payload[:content] == "hello"
|
13
|
+
# end
|
14
|
+
class Client
|
15
|
+
include EventEmitter
|
16
|
+
|
17
|
+
# @!visibility private
|
18
|
+
# The default properties for the identify packet
|
19
|
+
DEFAULT_PROPERTIES = {
|
20
|
+
'$os': Gem::Platform.local.os,
|
21
|
+
'$browser': 'vox',
|
22
|
+
'$device': 'vox'
|
23
|
+
}.freeze
|
24
|
+
|
25
|
+
# @!visibility private
|
26
|
+
# A hash of opcodes => op_names, as well as op_names => opcodes.
|
27
|
+
OPCODES = {
|
28
|
+
0 => :DISPATCH,
|
29
|
+
1 => :HEARTBEAT,
|
30
|
+
2 => :IDENTIFY,
|
31
|
+
3 => :PRESENCE_UPDATE,
|
32
|
+
4 => :VOICE_STATE_UPDATE,
|
33
|
+
5 => :UNKNOWN,
|
34
|
+
6 => :RESUME,
|
35
|
+
7 => :RECONNECT,
|
36
|
+
8 => :REQUEST_GUILD_MEMBERS,
|
37
|
+
9 => :INVALID_SESSION,
|
38
|
+
10 => :HELLO,
|
39
|
+
11 => :HEARTBEAT_ACK
|
40
|
+
}.tap { |ops| ops.merge!(ops.invert) }.freeze
|
41
|
+
|
42
|
+
# The gateway version to request.
|
43
|
+
GATEWAY_VERSION = '8'
|
44
|
+
|
45
|
+
# Class that holds information about a session.
|
46
|
+
Session = Struct.new(:id, :seq)
|
47
|
+
|
48
|
+
# @return [Session] The connection's session information.
|
49
|
+
attr_reader :session
|
50
|
+
|
51
|
+
# @param url [String] The url to use when connecting to the websocket. This can be
|
52
|
+
# retrieved from the API with {HTTP::Routes::Gateway#get_gateway_bot}.
|
53
|
+
# @param token [String] The token to use for authorization.
|
54
|
+
# @param port [Integer] The port to use when connecting. If `nil`, it will be inferred
|
55
|
+
# from the URL scheme (80 for `ws`, and 443 for `wss`).
|
56
|
+
# @param encoding [:json] This only accepts `json` currently, but may support `:etf` in future versions.
|
57
|
+
# @param compress [true, false] Whether to use `zlib-stream` compression.
|
58
|
+
# @param shard [Array<Integer>] An array in the format `[ShardNumber, TotalShards]`.
|
59
|
+
# @param large_threshold [Integer]
|
60
|
+
# @param presence [Object]
|
61
|
+
# @param intents [Integer]
|
62
|
+
def initialize(url:, token:, port: nil, encoding: :json, compress: true, shard: [0, 1],
|
63
|
+
properties: DEFAULT_PROPERTIES, large_threshold: nil, presence: nil, intents: nil)
|
64
|
+
uri = create_gateway_uri(url, port: port, encoding: encoding, compress: compress)
|
65
|
+
|
66
|
+
@encoding = encoding
|
67
|
+
raise ArgumentError, 'Invalid gateway encoding' unless %i[json etf].include? @encoding
|
68
|
+
|
69
|
+
if @encoding == :etf
|
70
|
+
begin
|
71
|
+
require 'vox/etf'
|
72
|
+
rescue LoadError
|
73
|
+
Logging.logger[self].error { 'ETF parsing lib not found. Please install vox-etf to use ETF encoding.' }
|
74
|
+
raise Vox::Error.new('ETF lib not found')
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
@websocket = WebSocket.new(uri.to_s, port: uri.port, compression: compress)
|
79
|
+
@identify_opts = {
|
80
|
+
token: token, properties: properties, shard: shard,
|
81
|
+
large_threshold: large_threshold, presence: presence, intents: intents
|
82
|
+
}.compact
|
83
|
+
@session = Session.new
|
84
|
+
@should_reconnect = Queue.new
|
85
|
+
setup_handlers
|
86
|
+
end
|
87
|
+
|
88
|
+
# @!method on(event, &block)
|
89
|
+
# Register an event handler for a GATEWAY event, or DISPATCH event.
|
90
|
+
# When registering an event corresponding to an opcode, the full payload
|
91
|
+
# is yielded. When registering a DISPATCH type, only the data portion
|
92
|
+
# of the payload is provided.
|
93
|
+
|
94
|
+
# Connect the websocket to the gateway.
|
95
|
+
def connect(async: false)
|
96
|
+
@ws_thread = Thread.new do
|
97
|
+
loop do
|
98
|
+
@websocket.connect
|
99
|
+
@websocket.thread.join
|
100
|
+
break unless @should_reconnect.shift
|
101
|
+
end
|
102
|
+
end
|
103
|
+
async ? @ws_thread : @ws_thread.join
|
104
|
+
end
|
105
|
+
|
106
|
+
# Close the websocket.
|
107
|
+
# @param code [Integer] The close code.
|
108
|
+
# @param reason [String] The reason for closing.
|
109
|
+
def close(reason = nil, code = 1000, reconnect: false)
|
110
|
+
@ws_thread.kill unless reconnect
|
111
|
+
@websocket.close(reason, code)
|
112
|
+
@websocket.thread.join unless reconnect
|
113
|
+
end
|
114
|
+
|
115
|
+
# Send a packet with the correct encoding. Only supports JSON currently.
|
116
|
+
# @param op_code [Integer]
|
117
|
+
# @param data [Hash]
|
118
|
+
def send_packet(op_code, data)
|
119
|
+
LOGGER.debug { "Sending #{op_code.is_a?(Symbol) ? op_code : OPCODES[op_code]} #{data || 'nil'}" }
|
120
|
+
if @encoding == :etf
|
121
|
+
send_etf_packet(op_code, data)
|
122
|
+
else
|
123
|
+
send_json_packet(op_code, data)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Request a guild member chunk, used to build a member cache.
|
128
|
+
# @param guild_id [String, Integer]
|
129
|
+
# @param query
|
130
|
+
# @param limit [Integer]
|
131
|
+
# @param presences
|
132
|
+
# @param user_ids [Array<String, Integer>]
|
133
|
+
# @param nonce [String, Integer]
|
134
|
+
def request_guild_members(guild_id, query: nil, limit: 0, presences: nil,
|
135
|
+
user_ids: nil, nonce: nil)
|
136
|
+
opts = {
|
137
|
+
guild_id: guild_id, query: query, limit: limit, presences: presences,
|
138
|
+
user_ids: user_ids, nonce: nonce
|
139
|
+
}.compact
|
140
|
+
|
141
|
+
send_packet(OPCODES[:REQUEST_GUILD_MEMBERS], opts)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Send a voice state update, used for establishing voice connections.
|
145
|
+
# @param guild_id [String, Integer]
|
146
|
+
# @param channel_id [String, Integer]
|
147
|
+
# @param self_mute [true, false]
|
148
|
+
# @param self_deaf [true, false]
|
149
|
+
def voice_state_update(guild_id, channel_id, self_mute: false, self_deaf: false)
|
150
|
+
opts = {
|
151
|
+
guild_id: guild_id, channel_id: channel_id, self_mute: self_mute,
|
152
|
+
self_deaf: self_deaf
|
153
|
+
}.compact
|
154
|
+
|
155
|
+
send_packet(OPCODES[:VOICE_STATE_UPDATE], opts)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Update the bot's status.
|
159
|
+
# @param status [String] The user's new status.
|
160
|
+
# @param afk [true, false] Whether or not the client is AFK.
|
161
|
+
# @param game [Hash<Symbol, Object>, nil] An [activity object](https://discord.com/developers/docs/topics/gateway#activity-object).
|
162
|
+
# @param since [Integer, nil] Unix time (in milliseconds) of when the client went idle.
|
163
|
+
def presence_update(status:, afk: false, game: nil, since: nil)
|
164
|
+
opts = { status: status, afk: afk, game: game, since: since }.compact
|
165
|
+
send_packet(OPCODES[:PRESENCE_UPDATE], opts)
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
# Add internal event handlers
|
171
|
+
def setup_handlers
|
172
|
+
# Discord will contact us with HELLO first, so we don't need to hook into READY
|
173
|
+
@websocket.on(:message, &method(:handle_message))
|
174
|
+
@websocket.on(:close, &method(:handle_close))
|
175
|
+
|
176
|
+
# Setup payload handlers
|
177
|
+
on(:DISPATCH, &method(:handle_dispatch))
|
178
|
+
on(:HEARTBEAT, &method(:handle_heartbeat))
|
179
|
+
on(:RECONNECT, &method(:handle_reconnect))
|
180
|
+
on(:INVALID_SESSION, &method(:handle_invalid_session))
|
181
|
+
on(:HELLO, &method(:handle_hello))
|
182
|
+
on(:HEARTBEAT_ACK, &method(:handle_heartbeat_ack))
|
183
|
+
on(:READY, &method(:handle_ready))
|
184
|
+
end
|
185
|
+
|
186
|
+
# Create a URI from a gateway url and options
|
187
|
+
# @param url [String]
|
188
|
+
# @param port [Integer]
|
189
|
+
# @param encoding [:json]
|
190
|
+
# @param compress [true, false]
|
191
|
+
# @return [URI::Generic]
|
192
|
+
def create_gateway_uri(url, port: nil, encoding: :json, compress: true)
|
193
|
+
compression = compress ? 'zlib-stream' : nil
|
194
|
+
query = URI.encode_www_form(
|
195
|
+
version: GATEWAY_VERSION, encoding: encoding, compress: compression
|
196
|
+
)
|
197
|
+
URI(url).tap do |u|
|
198
|
+
u.query = query
|
199
|
+
u.port = port
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Send a JSON packet.
|
204
|
+
# @param op_code [Integer]
|
205
|
+
# @param data [Hash]
|
206
|
+
def send_json_packet(op_code, data)
|
207
|
+
payload = { op: op_code, d: data }
|
208
|
+
|
209
|
+
@websocket.send_json(payload)
|
210
|
+
end
|
211
|
+
|
212
|
+
# Send an ETF packet.
|
213
|
+
# @param op_code [Integer]
|
214
|
+
# @param data [Hash]
|
215
|
+
def send_etf_packet(op_code, data)
|
216
|
+
payload = { op: op_code, d: data }
|
217
|
+
@websocket.send_binary(Vox::ETF.encode(payload))
|
218
|
+
end
|
219
|
+
|
220
|
+
# Send an identify payload to discord, beginning a new session.
|
221
|
+
def send_identify
|
222
|
+
send_packet(OPCODES[:IDENTIFY], @identify_opts)
|
223
|
+
end
|
224
|
+
|
225
|
+
# Send a resume payload to discord, attempting to resume an existing
|
226
|
+
# session.
|
227
|
+
def send_resume
|
228
|
+
send_packet(OPCODES[:RESUME],
|
229
|
+
{ token: @identify_opts[:token], session_id: @session.id, seq: @session.seq })
|
230
|
+
end
|
231
|
+
|
232
|
+
# Send a heartbeat.
|
233
|
+
def send_heartbeat
|
234
|
+
@heartbeat_acked = false
|
235
|
+
send_packet(OPCODES[:HEARTBEAT], @session.seq)
|
236
|
+
end
|
237
|
+
|
238
|
+
# A loop that handles sending and receiving heartbeats from the gateway.
|
239
|
+
def heartbeat_loop
|
240
|
+
loop do
|
241
|
+
send_heartbeat
|
242
|
+
sleep @heartbeat_interval
|
243
|
+
next if @heartbeat_acked
|
244
|
+
|
245
|
+
LOGGER.error { 'Heartbeat was not acked, reconnecting.' }
|
246
|
+
@websocket.close
|
247
|
+
break
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
##################################
|
252
|
+
##################################
|
253
|
+
##################################
|
254
|
+
##################################
|
255
|
+
#### ####
|
256
|
+
#### Internal event handlers ####
|
257
|
+
#### ####
|
258
|
+
##################################
|
259
|
+
##################################
|
260
|
+
##################################
|
261
|
+
##################################
|
262
|
+
|
263
|
+
# Handle a message from the websocket.
|
264
|
+
# @param data [String] The message data.
|
265
|
+
def handle_message(data)
|
266
|
+
if @encoding == :etf
|
267
|
+
handle_etf_message(data)
|
268
|
+
else
|
269
|
+
handle_json_message(data)
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Handle an ETF message, decoding it and emitting an event.
|
274
|
+
# @param data [String] The ETF data.
|
275
|
+
def handle_etf_message(data)
|
276
|
+
data = Vox::ETF.decode(data)
|
277
|
+
LOGGER.debug { "Emitting #{OPCODES[data[:op]]}" } if OPCODES[data[:op]] != :DISPATCH
|
278
|
+
|
279
|
+
@session.seq = data[:s] if data[:s]
|
280
|
+
op = OPCODES[data[:op]]
|
281
|
+
|
282
|
+
emit(op, data)
|
283
|
+
end
|
284
|
+
|
285
|
+
# Handle a JSON message, decoding it and emitting an event.
|
286
|
+
# @param json [String] The JSON data.
|
287
|
+
def handle_json_message(json)
|
288
|
+
data = MultiJson.load(json, symbolize_keys: true)
|
289
|
+
# Don't announce DISPATCH events since we log it on the same level
|
290
|
+
# in the dispatch handler.
|
291
|
+
LOGGER.debug { "Emitting #{OPCODES[data[:op]]}" } if OPCODES[data[:op]] != :DISPATCH
|
292
|
+
|
293
|
+
@session.seq = data[:s] if data[:s]
|
294
|
+
op = OPCODES[data[:op]]
|
295
|
+
|
296
|
+
emit(op, data)
|
297
|
+
end
|
298
|
+
|
299
|
+
# Handle a dispatch event, extracting the event name and emitting an event.
|
300
|
+
# @param payload [Hash<Symbol, Object>] The decoded payload's `data` field.
|
301
|
+
def handle_dispatch(payload)
|
302
|
+
LOGGER.debug { "Emitting #{payload[:t]}" }
|
303
|
+
emit(payload[:t], payload[:d])
|
304
|
+
end
|
305
|
+
|
306
|
+
# Handle a hello event, beginning the heartbeat loop and identifying or
|
307
|
+
# resuming.
|
308
|
+
# @param payload [Hash<Symbol, Object>] The decoded payload.
|
309
|
+
def handle_hello(payload)
|
310
|
+
LOGGER.info { 'Connected' }
|
311
|
+
@heartbeat_interval = payload[:d][:heartbeat_interval] / 1000
|
312
|
+
@heartbeat_thread = Thread.new { heartbeat_loop }
|
313
|
+
if @session.seq
|
314
|
+
send_resume
|
315
|
+
else
|
316
|
+
send_identify
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
# Fired if the gateway requests that we send a heartbeat.
|
321
|
+
# @param _payload [Object] The received payload, not used in this method.
|
322
|
+
def handle_heartbeat(_payload)
|
323
|
+
send_packet(OPCODES[:HEARTBEAT], @session.seq)
|
324
|
+
end
|
325
|
+
|
326
|
+
# Set session information from the ready payload.
|
327
|
+
# @param payload [Object] The received ready payload.
|
328
|
+
def handle_ready(payload)
|
329
|
+
@session.id = payload[:session_id]
|
330
|
+
end
|
331
|
+
|
332
|
+
# @param _payload [Object] The received payload, not used in this method.
|
333
|
+
def handle_invalid_session(_payload)
|
334
|
+
@session.seq = nil
|
335
|
+
send_identify
|
336
|
+
end
|
337
|
+
|
338
|
+
# @param _payload [Object] The received payload, not used in this method.
|
339
|
+
def handle_reconnect(_payload)
|
340
|
+
@websocket.close('Received reconnect', 4000)
|
341
|
+
end
|
342
|
+
|
343
|
+
# Handle a heartbeat acknowledgement from the gateway.
|
344
|
+
# @param _payload [Object] The received payload, not used in this method.
|
345
|
+
def handle_heartbeat_ack(_payload)
|
346
|
+
@heartbeat_acked = true
|
347
|
+
end
|
348
|
+
|
349
|
+
# Handle a close event from the websocket.
|
350
|
+
# @param data [Hash{:code => Integer, :reason => String}]
|
351
|
+
def handle_close(data)
|
352
|
+
LOGGER.warn { "Websocket closed (#{data[:code]} #{data[:reason]})" }
|
353
|
+
@heartbeat_thread&.kill
|
354
|
+
reconnect = true
|
355
|
+
|
356
|
+
case data[:code]
|
357
|
+
# Invalid seq when resuming, or session timed out
|
358
|
+
when 4007, 4009
|
359
|
+
LOGGER.error { 'Invalid session, reconnecting.' }
|
360
|
+
@session = Session.new
|
361
|
+
when 4003, 4004, 4011
|
362
|
+
LOGGER.fatal { data[:reason] }
|
363
|
+
reconnect = false
|
364
|
+
else
|
365
|
+
LOGGER.error { data[:reason] } if data[:reason]
|
366
|
+
end
|
367
|
+
|
368
|
+
@should_reconnect << reconnect
|
369
|
+
end
|
370
|
+
|
371
|
+
# @!visibility private
|
372
|
+
# The logger for Vox::Gateway::Client
|
373
|
+
LOGGER = Logging.logger[self]
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'event_emitter'
|
4
|
+
require 'logging'
|
5
|
+
require 'multi_json'
|
6
|
+
require 'websocket/driver'
|
7
|
+
require 'socket'
|
8
|
+
require 'openssl'
|
9
|
+
require 'uri'
|
10
|
+
require 'zlib'
|
11
|
+
|
12
|
+
module Vox
|
13
|
+
# Module containing the gateway component.
|
14
|
+
module Gateway
|
15
|
+
# Websocket that handles data interchange for {Vox::Gateway::Client}.
|
16
|
+
class WebSocket
|
17
|
+
include EventEmitter
|
18
|
+
|
19
|
+
# Zlib boundary used for separating messages split into multiple frames.
|
20
|
+
ZLIB_SUFFIX = "\x00\x00\xFF\xFF".b.freeze
|
21
|
+
|
22
|
+
attr_reader :url, :thread, :driver
|
23
|
+
|
24
|
+
def initialize(url, port: nil, compression: true)
|
25
|
+
@url = url
|
26
|
+
@uri = URI.parse(url)
|
27
|
+
@port = port || @uri.scheme == 'wss' ? 443 : 80
|
28
|
+
@inflate = Zlib::Inflate.new if compression
|
29
|
+
end
|
30
|
+
|
31
|
+
# @!method on(key, data)
|
32
|
+
# @overload on('open', &block)
|
33
|
+
# Emitted when the websocket finishes its connecting process.
|
34
|
+
# @overload on('message', &block)
|
35
|
+
# Emitted when a message is parsed from the websocket.
|
36
|
+
# @yieldparam [String] data The received message.
|
37
|
+
# @overload on('close', &block)
|
38
|
+
# Emitted when the websocket connection closes.
|
39
|
+
# @yieldparam [Integer] code The given close code.
|
40
|
+
# @yieldparam [String, nil] reason The reason the websocket was closed.
|
41
|
+
|
42
|
+
# Connect to the websocket server.
|
43
|
+
# @return [Thread] The thread handling the read loop.
|
44
|
+
def connect
|
45
|
+
# Flush the zlib buffer
|
46
|
+
@inflate&.reset
|
47
|
+
|
48
|
+
# Create a socket connection to the URL
|
49
|
+
@socket = create_socket
|
50
|
+
|
51
|
+
# Initialize the websocket driver
|
52
|
+
setup_driver
|
53
|
+
|
54
|
+
# Read until our websocket closes.
|
55
|
+
@thread = Thread.new do
|
56
|
+
read_loop
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Send a `text` message.
|
61
|
+
# @param message [String] The `text` message to write to the websocket.
|
62
|
+
# @return [true, false] Whether the message was sent successfully.
|
63
|
+
def send(message)
|
64
|
+
LOGGER.debug { "[OUT] #{message} " }
|
65
|
+
@driver.text(message)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Serialize a hash to send as a `text` message.
|
69
|
+
# @param hash [Hash] The hash to serialize and send as a `text` message.
|
70
|
+
# @return [true, false] Whether the message was sent successfully.
|
71
|
+
def send_json(hash)
|
72
|
+
data = MultiJson.dump(hash)
|
73
|
+
send(data)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Send a `binary` frame.
|
77
|
+
# @param data [String] The binary data to write to the websocket.
|
78
|
+
# @return [true, false] Whether the data was send successfully.
|
79
|
+
def send_binary(data)
|
80
|
+
@driver.binary(data)
|
81
|
+
end
|
82
|
+
|
83
|
+
# @!visibility private
|
84
|
+
# @param data [String] The data to write to the socket.
|
85
|
+
def write(data)
|
86
|
+
@socket.write(data)
|
87
|
+
end
|
88
|
+
|
89
|
+
# @!visibility private
|
90
|
+
# @return [nil]
|
91
|
+
def read
|
92
|
+
@driver.parse(@socket.readpartial(4096))
|
93
|
+
end
|
94
|
+
|
95
|
+
# Close the websocket connection.
|
96
|
+
# @param reason [String] The reason for closing the websocket.
|
97
|
+
# @param code [Integer] The code to close the websocket with.
|
98
|
+
# @return [true, false] Whether the websocket closed successfully.
|
99
|
+
def close(reason = nil, code = 1000)
|
100
|
+
@driver.close(reason, code)
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
# Read from the socket until the websocket driver
|
106
|
+
# reports as closed.
|
107
|
+
def read_loop
|
108
|
+
read until @driver.state == :closed
|
109
|
+
rescue SystemCallError => e
|
110
|
+
LOGGER.error { "(#{e.class.name.split('::').last}) #{e.message}" }
|
111
|
+
rescue EOFError => e
|
112
|
+
LOGGER.error { 'EOF in websocket loop' }
|
113
|
+
end
|
114
|
+
|
115
|
+
def setup_driver
|
116
|
+
@driver = ::WebSocket::Driver.client(self)
|
117
|
+
register_handlers
|
118
|
+
@driver.start
|
119
|
+
end
|
120
|
+
|
121
|
+
# Create a socket, create an SSL socket instead for
|
122
|
+
# wss.
|
123
|
+
# @return [TCPSocket, SSLSocket]
|
124
|
+
def create_socket
|
125
|
+
if @uri.scheme == 'wss'
|
126
|
+
create_ssl_socket.tap(&:connect)
|
127
|
+
else
|
128
|
+
TCPSocket.new(@uri.host, @port)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Create an SSL socket for WSS.
|
133
|
+
def create_ssl_socket
|
134
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
135
|
+
ctx.set_params ssl_version: :TLSv1_2
|
136
|
+
|
137
|
+
socket = TCPSocket.new(@uri.host, @port)
|
138
|
+
OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Register the base handlers.
|
142
|
+
def register_handlers
|
143
|
+
@driver.on(:open, &method(:on_open))
|
144
|
+
@driver.on(:message, &method(:on_message))
|
145
|
+
@driver.on(:close, &method(:on_close))
|
146
|
+
end
|
147
|
+
|
148
|
+
# Handle open events.
|
149
|
+
def on_open(_event)
|
150
|
+
LOGGER.debug { 'Connection open' }
|
151
|
+
emit(:open)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Handle parsed message events.
|
155
|
+
def on_message(event)
|
156
|
+
data = if @inflate
|
157
|
+
packed = event.data.pack('c*')
|
158
|
+
@inflate << packed
|
159
|
+
return unless packed.end_with?(ZLIB_SUFFIX)
|
160
|
+
|
161
|
+
@inflate.inflate('')
|
162
|
+
else
|
163
|
+
event.data
|
164
|
+
end
|
165
|
+
|
166
|
+
LOGGER.debug { "[IN] #{data[0].ord == 131 ? data.inspect : data}" }
|
167
|
+
emit(:message, data)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Handle close events.
|
171
|
+
def on_close(event)
|
172
|
+
LOGGER.debug { "WebSocket is closing (#{event.code}) #{event.reason}" }
|
173
|
+
emit(:close, { code: event.code, reason: event.reason })
|
174
|
+
end
|
175
|
+
|
176
|
+
# The logger used for output.
|
177
|
+
LOGGER = Logging.logger[self]
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
data/lib/vox/http/client.rb
CHANGED
@@ -3,14 +3,7 @@
|
|
3
3
|
require 'faraday'
|
4
4
|
require 'logging'
|
5
5
|
require 'securerandom'
|
6
|
-
|
7
|
-
# require 'vox/http/routes/channel'
|
8
|
-
# require 'vox/http/routes/emoji'
|
9
|
-
# require 'vox/http/routes/guild'
|
10
|
-
# require 'vox/http/routes/invite'
|
11
|
-
# require 'vox/http/routes/user'
|
12
|
-
# require 'vox/http/routes/voice'
|
13
|
-
# require 'vox/http/routes/webhook'
|
6
|
+
require 'vox'
|
14
7
|
require 'vox/http/route'
|
15
8
|
require 'vox/http/routes'
|
16
9
|
require 'vox/http/error'
|
data/lib/vox/http/routes.rb
CHANGED
@@ -4,6 +4,7 @@ require 'vox/http/routes/audit_log'
|
|
4
4
|
require 'vox/http/routes/channel'
|
5
5
|
require 'vox/http/routes/emoji'
|
6
6
|
require 'vox/http/routes/guild'
|
7
|
+
require 'vox/http/routes/gateway'
|
7
8
|
require 'vox/http/routes/invite'
|
8
9
|
require 'vox/http/routes/user'
|
9
10
|
require 'vox/http/routes/voice'
|
@@ -15,7 +16,7 @@ module Vox
|
|
15
16
|
module Routes
|
16
17
|
# Include all route containers if this module is included
|
17
18
|
def self.included(klass)
|
18
|
-
|
19
|
+
constants.each { |m| klass.include const_get(m) }
|
19
20
|
end
|
20
21
|
end
|
21
22
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'vox/http/route'
|
4
|
+
|
5
|
+
module Vox
|
6
|
+
module HTTP
|
7
|
+
module Routes
|
8
|
+
# Mixin for gateway routes, used for retriving information
|
9
|
+
# about connecting to the gateway.
|
10
|
+
module Gateway
|
11
|
+
# rubocop:disable Naming/AccessorMethodName
|
12
|
+
|
13
|
+
# Fetch the URL to use for a gateway connection.
|
14
|
+
# @return [Hash<:url, String>] An object with one key `url` that maps to the URL
|
15
|
+
# for connecting to the gateway.
|
16
|
+
# @vox.api_docs https://discord.com/developers/docs/topics/gateway#get-gateway
|
17
|
+
def get_gateway
|
18
|
+
request(Route.new(:GET, '/gateway'))
|
19
|
+
end
|
20
|
+
|
21
|
+
# Fetch the URL to use for a gateway connection, with additional sharding information.
|
22
|
+
# @return [Hash{ :url => String, :shards => Integer, :session_start_limit => Hash<Symbol, Integer>}]
|
23
|
+
# An object that includes the URL to connect to the gateway with, the recommended number of shards,
|
24
|
+
# as well as a [session start limit](https://discord.com/developers/docs/topics/gateway#session-start-limit-object-session-start-limit-structure)
|
25
|
+
# object.
|
26
|
+
# @vox.api_docs https://discord.com/developers/docs/topics/gateway#get-gateway-bot
|
27
|
+
def get_gateway_bot
|
28
|
+
request(Route.new(:GET, '/gateway/bot'))
|
29
|
+
end
|
30
|
+
|
31
|
+
# rubocop:enable Naming/AccessorMethodName
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/vox/http/util.rb
CHANGED
@@ -8,11 +8,9 @@ module Vox
|
|
8
8
|
module Util
|
9
9
|
# Remove members from a hash that have `:undef` as values
|
10
10
|
# @example
|
11
|
-
# ```ruby
|
12
11
|
# hash = { foo: 1, bar: :undef, baz: 2 }
|
13
12
|
# filter_hash(hash)
|
14
13
|
# # => { foo: 1, baz: 2 }
|
15
|
-
# ```
|
16
14
|
# @param hash [Hash] The hash to filter `:undef` members from.
|
17
15
|
# @return [Hash] The given hash with all members with an `:undef` value removed.
|
18
16
|
# @!visibility private
|
data/lib/vox/version.rb
CHANGED
data/vox.gemspec
CHANGED
@@ -28,15 +28,18 @@ Gem::Specification.new do |spec|
|
|
28
28
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
29
29
|
spec.require_paths = ['lib']
|
30
30
|
|
31
|
+
spec.add_runtime_dependency 'event_emitter', '~> 0.2.6'
|
31
32
|
spec.add_runtime_dependency 'faraday', '~> 1.0.1'
|
32
33
|
spec.add_runtime_dependency 'logging', '~> 2.3.0'
|
33
34
|
spec.add_runtime_dependency 'mime-types', '~> 3.3.1'
|
34
35
|
spec.add_runtime_dependency 'multi_json', '~> 1.15.0'
|
36
|
+
spec.add_runtime_dependency 'websocket-driver', '~> 0.7.3'
|
35
37
|
spec.add_development_dependency 'rake', '~> 12.0'
|
36
38
|
spec.add_development_dependency 'rspec', '~> 3.0'
|
37
39
|
spec.add_development_dependency 'rubocop', '~> 0.89.1'
|
38
40
|
spec.add_development_dependency 'rubocop-performance', '~> 1.7.1'
|
39
41
|
spec.add_development_dependency 'rubocop-rspec', '~> 1.42.0'
|
40
42
|
spec.add_development_dependency 'simplecov', '~> 0.17.1'
|
43
|
+
spec.add_development_dependency 'vox-etf', '~> 0.1.6'
|
41
44
|
spec.add_development_dependency 'yard', '~> 0.9.25'
|
42
45
|
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: vox
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matthew Carey
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: event_emitter
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.2.6
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.2.6
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: faraday
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -66,6 +80,20 @@ dependencies:
|
|
66
80
|
- - "~>"
|
67
81
|
- !ruby/object:Gem::Version
|
68
82
|
version: 1.15.0
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: websocket-driver
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 0.7.3
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.7.3
|
69
97
|
- !ruby/object:Gem::Dependency
|
70
98
|
name: rake
|
71
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -150,6 +178,20 @@ dependencies:
|
|
150
178
|
- - "~>"
|
151
179
|
- !ruby/object:Gem::Version
|
152
180
|
version: 0.17.1
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: vox-etf
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - "~>"
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: 0.1.6
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - "~>"
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: 0.1.6
|
153
195
|
- !ruby/object:Gem::Dependency
|
154
196
|
name: yard
|
155
197
|
requirement: !ruby/object:Gem::Requirement
|
@@ -185,7 +227,10 @@ files:
|
|
185
227
|
- LICENSE.md
|
186
228
|
- README.md
|
187
229
|
- Rakefile
|
230
|
+
- examples/gateway_example.rb
|
188
231
|
- lib/vox.rb
|
232
|
+
- lib/vox/gateway/client.rb
|
233
|
+
- lib/vox/gateway/websocket.rb
|
189
234
|
- lib/vox/http/client.rb
|
190
235
|
- lib/vox/http/error.rb
|
191
236
|
- lib/vox/http/middleware.rb
|
@@ -196,6 +241,7 @@ files:
|
|
196
241
|
- lib/vox/http/routes/audit_log.rb
|
197
242
|
- lib/vox/http/routes/channel.rb
|
198
243
|
- lib/vox/http/routes/emoji.rb
|
244
|
+
- lib/vox/http/routes/gateway.rb
|
199
245
|
- lib/vox/http/routes/guild.rb
|
200
246
|
- lib/vox/http/routes/invite.rb
|
201
247
|
- lib/vox/http/routes/user.rb
|
@@ -227,7 +273,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
227
273
|
- !ruby/object:Gem::Version
|
228
274
|
version: '0'
|
229
275
|
requirements: []
|
230
|
-
rubygems_version: 3.
|
276
|
+
rubygems_version: 3.1.2
|
231
277
|
signing_key:
|
232
278
|
specification_version: 4
|
233
279
|
summary: Discord library
|