vox 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|