onyxcord 1.1.8 → 2.0.0

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.
@@ -1,107 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This file uses code from Websocket::Client::Simple, licensed under the following license:
4
- #
5
- # Copyright (c) 2013-2014 Sho Hashimoto
6
- #
7
- # MIT License
8
- #
9
- # Permission is hereby granted, free of charge, to any person obtaining
10
- # a copy of this software and associated documentation files (the
11
- # "Software"), to deal in the Software without restriction, including
12
- # without limitation the rights to use, copy, modify, merge, publish,
13
- # distribute, sublicense, and/or sell copies of the Software, and to
14
- # permit persons to whom the Software is furnished to do so, subject to
15
- # the following conditions:
16
- #
17
- # The above copyright notice and this permission notice shall be
18
- # included in all copies or substantial portions of the Software.
19
- #
20
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
- # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
- # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23
- # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24
- # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25
- # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26
- # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27
-
3
+ require 'async'
4
+ require 'async/http/endpoint'
5
+ require 'async/websocket/client'
28
6
  require 'onyxcord/rate_limiter/gateway'
29
7
 
30
8
  module OnyxCord
31
9
  # Gateway packet opcodes
32
10
  module Opcodes
33
- # **Received** when Discord dispatches an event to the gateway (like MESSAGE_CREATE, PRESENCE_UPDATE or whatever).
34
- # The vast majority of received packets will have this opcode.
35
11
  DISPATCH = 0
36
-
37
- # **Two-way**: The client has to send a packet with this opcode every ~40 seconds (actual interval specified in
38
- # READY or RESUMED) and the current sequence number, otherwise it will be disconnected from the gateway. In certain
39
- # cases Discord may also send one, specifically if two clients are connected at once.
40
12
  HEARTBEAT = 1
41
-
42
- # **Sent**: This is one of the two possible ways to initiate a session after connecting to the gateway. It
43
- # should contain the authentication token along with other stuff the server has to know right from the start, such
44
- # as large_threshold and, for older gateway versions, the desired version.
45
13
  IDENTIFY = 2
46
-
47
- # **Sent**: Packets with this opcode are used to change the user's status and played game. (Sending this is never
48
- # necessary for a gateway client to behave correctly)
49
14
  PRESENCE = 3
50
-
51
- # **Sent**: Packets with this opcode are used to change a user's voice state (mute/deaf/unmute/undeaf/etc.). It is
52
- # also used to connect to a voice server in the first place. (Sending this is never necessary for a gateway client
53
- # to behave correctly)
54
15
  VOICE_STATE = 4
55
-
56
- # **Sent**: This opcode is used to ping a voice server, whatever that means. The functionality of this opcode isn't
57
- # known well but non-user clients should never send it.
58
16
  VOICE_PING = 5
59
-
60
- # **Sent**: This is the other of two possible ways to initiate a gateway session (other than {IDENTIFY}). Rather
61
- # than starting an entirely new session, it resumes an existing session by replaying all events from a given
62
- # sequence number. It should be used to recover from a connection error or anything like that when the session is
63
- # still valid - sending this with an invalid session will cause an error to occur.
64
17
  RESUME = 6
65
-
66
- # **Received**: Discord sends this opcode to indicate that the client should reconnect to a different gateway
67
- # server because the old one is currently being decommissioned. Counterintuitively, this opcode also invalidates the
68
- # session - the client has to create an entirely new session with the new gateway instead of resuming the old one.
69
18
  RECONNECT = 7
70
-
71
- # **Sent**: This opcode identifies packets used to retrieve a list of members from a particular server. There is
72
- # also a REST endpoint available for this, but it is inconvenient to use because the client has to implement
73
- # pagination itself, whereas sending this opcode lets Discord handle the pagination and the client can just add
74
- # members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
75
19
  REQUEST_MEMBERS = 8
76
-
77
- # **Received**: Sent by Discord when the session becomes invalid for any reason. This may include improperly
78
- # resuming existing sessions, attempting to start sessions with invalid data, or something else entirely. The client
79
- # should handle this by simply starting a new session.
80
20
  INVALIDATE_SESSION = 9
81
-
82
- # **Received**: Sent immediately for any opened connection; tells the client to start heartbeating early on, so the
83
- # server can safely search for a session server to handle the connection without the connection being terminated.
84
- # As a side-effect, large bots are less likely to disconnect because of very large READY parse times.
85
21
  HELLO = 10
86
-
87
- # **Received**: Returned after a heartbeat was sent to the server. This allows clients to identify and deal with
88
- # zombie connections that don't dispatch any events anymore.
89
22
  HEARTBEAT_ACK = 11
90
23
  end
91
24
 
92
- # This class stores the data of an active gateway session. Note that this is different from a websocket connection -
93
- # there may be multiple sessions per connection or one session may persist over multiple connections.
25
+ # Stores data of an active gateway session.
94
26
  class Session
95
- # @return [String] Used to uniquely identify this session. Mostly used when resuming connections.
96
- attr_reader :session_id
97
-
98
- # @return [Integer] Incrementing integer used to determine the most recent event reccived from Discord.
27
+ attr_reader :session_id, :resume_gateway_url
99
28
  attr_accessor :sequence
100
29
 
101
- # @return [String] Gateway URL used to reconnect to the gateway node that Discord wants this session to use.
102
- attr_reader :resume_gateway_url
103
-
104
- # @!visibility private
105
30
  def initialize(session_id, resume_gateway_url)
106
31
  @session_id = session_id
107
32
  @sequence = 0
@@ -110,7 +35,6 @@ module OnyxCord
110
35
  @resume_gateway_url = resume_gateway_url
111
36
  end
112
37
 
113
- # Flags this session as suspended, so we know not to try and send heartbeats, etc. to the gateway until we've reconnected
114
38
  def suspend
115
39
  @suspended = true
116
40
  end
@@ -119,12 +43,10 @@ module OnyxCord
119
43
  @suspended
120
44
  end
121
45
 
122
- # Flags this session as no longer being suspended, so we can resume
123
46
  def resume
124
47
  @suspended = false
125
48
  end
126
49
 
127
- # Flags this session as being invalid
128
50
  def invalidate
129
51
  @invalid = true
130
52
  @resume_gateway_url = nil
@@ -139,163 +61,100 @@ module OnyxCord
139
61
  end
140
62
  end
141
63
 
142
- # Client for the Discord gateway protocol
64
+ # Client for the Discord gateway protocol — fully async.
143
65
  class Gateway
144
- # How many members there need to be in a server for it to count as "large"
145
66
  LARGE_THRESHOLD = 100
146
-
147
- # The version of the gateway that's supposed to be used.
148
67
  GATEWAY_VERSION = 9
149
-
150
- # Close codes that are unrecoverable, after which we should not try to reconnect.
151
- # - 4003: Not authenticated. How did this happen?
152
- # - 4004: Authentication failed. Token was wrong, nothing we can do.
153
- # - 4011: Sharding required. Currently requires developer intervention.
154
- # - 4014: Use of disabled privileged intents.
155
68
  FATAL_CLOSE_CODES = [4003, 4004, 4011, 4014].freeze
156
69
 
157
- # Heartbeat ACKs are Discord's way of verifying on the client side whether the connection is still alive. If this is
158
- # set to true (default value) the gateway client will use that functionality to detect zombie connections and
159
- # reconnect in such a case; however it may lead to instability if there's some problem with the ACKs. If this occurs
160
- # it can simply be set to false.
161
- # @return [true, false] whether or not this gateway should check for heartbeat ACKs.
162
70
  attr_accessor :check_heartbeat_acks
163
-
164
- # @return [Integer] the intent parameter sent to the gateway server.
165
71
  attr_reader :intents
166
72
 
167
73
  def initialize(bot, token, shard_key = nil, compress_mode = :stream, intents = ALL_INTENTS)
168
74
  @token = token
169
75
  @bot = bot
170
-
171
76
  @shard_key = shard_key
172
-
173
- # Whether the connection to the gateway has succeeded yet
174
77
  @ws_success = false
175
-
176
78
  @check_heartbeat_acks = true
177
-
178
79
  @compress_mode = compress_mode
179
80
  @intents = intents
180
81
  @send_limiter = OnyxCord::RateLimiter::Gateway.new
82
+ @connection = nil
83
+ @closed = true
84
+ @pipe_broken = false
181
85
  end
182
86
 
183
- # Connect to the gateway server in a separate thread
87
+ # Connect to the gateway server inside an Async reactor.
184
88
  def run_async
185
89
  @ws_thread = Thread.new do
186
- Thread.current[:onyxcord_name] = 'websocket'
187
- connect_loop
188
- LOGGER.warn('The WS loop exited! Not sure if this is a good thing')
90
+ Thread.current[:onyxcord_name] = 'gateway'
91
+ Async do |task|
92
+ @reactor_task = task
93
+ connect_loop
94
+ LOGGER.warn('The gateway loop exited!')
95
+ end
189
96
  end
190
97
 
191
- LOGGER.debug('WS thread created! Now waiting for confirmation that everything worked')
98
+ LOGGER.debug('Gateway thread created! Waiting for confirmation...')
192
99
  loop do
193
100
  sleep(0.5)
194
-
195
- if @ws_success
196
- LOGGER.debug('Confirmation received! Exiting run.')
197
- break
198
- end
199
-
200
- if @should_reconnect == false
201
- LOGGER.debug('Reconnection flag was unset. Exiting run.')
202
- break
203
- end
101
+ break if @ws_success
102
+ break if @should_reconnect == false
204
103
  end
205
104
  end
206
105
 
207
- # Prevents all further execution until the websocket thread stops (e.g. through a closed connection).
208
106
  def sync
209
- @ws_thread.join
107
+ @ws_thread&.join
210
108
  end
211
109
 
212
- # Whether the WebSocket connection to the gateway is currently open
213
110
  def open?
214
- @handshake&.finished? && !@closed
111
+ !@closed && @connection
215
112
  end
216
113
 
217
- # Stops the bot gracefully, disconnecting the websocket without immediately killing the thread. This means that
218
- # Discord is immediately aware of the closed connection and makes the bot appear offline instantly.
219
- #
220
- # If this method doesn't work or you're looking for something more drastic, use {#kill} instead.
221
114
  def stop
222
115
  @should_reconnect = false
223
116
  close
224
-
225
- # Return nil so command bots don't send a message
226
117
  nil
227
118
  end
228
119
 
229
- # Kills the websocket thread, stopping all connections to Discord.
230
120
  def kill
231
- @ws_thread.kill
121
+ @ws_thread&.kill
232
122
  end
233
123
 
234
- # Notifies the {#run_async} method that everything is ready and the caller can now continue (i.e. with syncing,
235
- # or with doing processing and then syncing)
236
124
  def notify_ready
237
125
  @ws_success = true
238
126
  end
239
127
 
240
- # Injects a reconnect event (op 7) into the event processor, causing Discord to reconnect to the given gateway URL.
241
- # If the URL is set to nil, it will reconnect and get an entirely new gateway URL. This method has not much use
242
- # outside of testing and implementing highly custom reconnect logic.
243
- # @param url [String, nil] the URL to connect to or nil if one should be obtained from Discord.
244
128
  def inject_reconnect(url = nil)
245
- # When no URL is specified, the data should be nil, as is the case with Discord-sent packets.
246
129
  data = url ? { url: url } : nil
247
-
248
- handle_message({
249
- op: Opcodes::RECONNECT,
250
- d: data
251
- }.to_json)
130
+ handle_message({ op: Opcodes::RECONNECT, d: data }.to_json)
252
131
  end
253
132
 
254
- # Injects a resume packet (op 6) into the gateway. If this is done with a running connection, it will cause an
255
- # error. It has no use outside of testing stuff that I know of, but if you want to use it anyway for some reason,
256
- # here it is.
257
- # @param seq [Integer, nil] The sequence ID to inject, or nil if the currently tracked one should be used.
258
133
  def inject_resume(seq)
259
134
  send_resume(raw_token, @session_id, seq || @sequence)
260
135
  end
261
136
 
262
- # Injects a terminal gateway error into the handler. Useful for testing the reconnect logic.
263
- # @param e [Exception] The exception object to inject.
264
137
  def inject_error(e)
265
138
  handle_internal_close(e)
266
139
  end
267
140
 
268
- # Sends a heartbeat with the last received packet's seq (to acknowledge that we have received it and all packets
269
- # before it), or if none have been received yet, with 0.
270
- # @see #send_heartbeat
271
141
  def heartbeat
272
142
  if check_heartbeat_acks
273
143
  unless @last_heartbeat_acked
274
- # We're in a bad situation - apparently the last heartbeat wasn't ACK'd, which means the connection is likely
275
- # a zombie. Reconnect
276
- LOGGER.warn('Last heartbeat was not acked, so this is a zombie connection! Reconnecting')
277
-
278
- # We can't send anything on zombie connections
144
+ LOGGER.warn('Last heartbeat was not acked zombie connection! Reconnecting')
279
145
  @pipe_broken = true
280
146
  reconnect
281
147
  return
282
148
  end
283
-
284
149
  @last_heartbeat_acked = false
285
150
  end
286
-
287
151
  send_heartbeat(@session ? @session.sequence : 0)
288
152
  end
289
153
 
290
- # Sends a heartbeat packet (op 1). This tells Discord that the current connection is still active and that the last
291
- # packets until the given sequence have been processed (in case of a resume).
292
- # @param sequence [Integer] The sequence number for which to send a heartbeat.
293
154
  def send_heartbeat(sequence)
294
155
  send_packet(Opcodes::HEARTBEAT, sequence)
295
156
  end
296
157
 
297
- # Identifies to Discord with the default parameters.
298
- # @see #send_identify
299
158
  def identify
300
159
  compress = @compress_mode == :large
301
160
  send_identify(@token, {
@@ -305,253 +164,105 @@ module OnyxCord
305
164
  }, compress, LARGE_THRESHOLD, @shard_key, @intents)
306
165
  end
307
166
 
308
- # Sends an identify packet (op 2). This starts a new session on the current connection and tells Discord who we are.
309
- # This can only be done once a connection.
310
- # @param token [String] The token with which to authorise the session. If it belongs to a bot account, it must be
311
- # prefixed with "Bot ".
312
- # @param properties [Hash<Symbol => String>] A list of properties for Discord to use in analytics. The following
313
- # keys are recognised:
314
- #
315
- # - "os" (recommended value: the operating system the bot is running on)
316
- # - "browser" (recommended value: library name)
317
- # - "device" (recommended value: library name)
318
- # - "referrer" (recommended value: empty)
319
- # - "referring_domain" (recommended value: empty)
320
- #
321
- # @param compress [true, false] Whether certain large packets should be compressed using zlib.
322
- # @param large_threshold [Integer] The member threshold after which a server counts as large and will have to have
323
- # its member list chunked.
324
- # @param shard_key [Array(Integer, Integer), nil] The shard key to use for sharding, represented as
325
- # [shard_id, num_shards], or nil if the bot should not be sharded.
326
167
  def send_identify(token, properties, compress, large_threshold, shard_key = nil, intents = ALL_INTENTS)
327
168
  data = {
328
- # Don't send a v anymore as it's entirely determined by the URL now
329
169
  token: token,
330
170
  properties: properties,
331
171
  compress: compress,
332
172
  large_threshold: large_threshold,
333
173
  intents: intents
334
174
  }
335
-
336
- # Don't include the shard key at all if it is nil as Discord checks for its mere existence
337
175
  data[:shard] = shard_key if shard_key
338
-
339
176
  send_packet(Opcodes::IDENTIFY, data)
340
177
  end
341
178
 
342
- # Sends a status update packet (op 3). This sets the bot user's status (online/idle/...) and game playing/streaming.
343
- # @param status [String] The status that should be set (`online`, `idle`, `dnd`, `invisible`).
344
- # @param since [Integer] The Unix timestamp in milliseconds when the status was set. Should only be provided when
345
- # `afk` is true.
346
- # @param game [Hash<Symbol => Object>, nil] `nil` if no game should be played, or a hash of `:game => "name"` if a
347
- # game should be played. The hash can also contain additional attributes for streaming statuses.
348
- # @param afk [true, false] Whether the status was set due to inactivity on the user's part.
349
179
  def send_status_update(status, since, game, afk)
350
- data = {
351
- status: status,
352
- since: since,
353
- game: game,
354
- afk: afk
355
- }
356
-
357
- send_packet(Opcodes::PRESENCE, data)
180
+ send_packet(Opcodes::PRESENCE, { status: status, since: since, game: game, afk: afk })
358
181
  end
359
182
 
360
- # Sends a voice state update packet (op 4). This packet can connect a user to a voice channel, update self mute/deaf
361
- # status in an existing voice connection, move the user to a new voice channel on the same server or disconnect an
362
- # existing voice connection.
363
- # @param server_id [Integer] The ID of the server on which this action should occur.
364
- # @param channel_id [Integer, nil] The channel ID to connect/move to, or `nil` to disconnect.
365
- # @param self_mute [true, false] Whether the user should itself be muted to everyone else.
366
- # @param self_deaf [true, false] Whether the user should be deaf towards other users.
367
183
  def send_voice_state_update(server_id, channel_id, self_mute, self_deaf)
368
- data = {
369
- guild_id: server_id,
370
- channel_id: channel_id,
371
- self_mute: self_mute,
372
- self_deaf: self_deaf
373
- }
374
-
375
- send_packet(Opcodes::VOICE_STATE, data)
184
+ send_packet(Opcodes::VOICE_STATE, {
185
+ guild_id: server_id, channel_id: channel_id,
186
+ self_mute: self_mute, self_deaf: self_deaf
187
+ })
376
188
  end
377
189
 
378
- # Resumes the session from the last recorded point.
379
- # @see #send_resume
380
190
  def resume
381
191
  send_resume(@token, @session.session_id, @session.sequence)
382
192
  end
383
193
 
384
- # Reconnects the gateway connection in a controlled manner.
385
- # @param attempt_resume [true, false] Whether a resume should be attempted after the reconnection.
386
194
  def reconnect(attempt_resume = true)
387
195
  @session.suspend if @session && attempt_resume
388
-
389
196
  @instant_reconnect = true
390
197
  @should_reconnect = true
391
-
392
198
  close(4000)
393
199
  end
394
200
 
395
- # Sends a resume packet (op 6). This replays all events from a previous point specified by its packet sequence. This
396
- # will not work if the packet to resume from has already been acknowledged using a heartbeat, or if the session ID
397
- # belongs to a now invalid session.
398
- #
399
- # If this packet is sent at the beginning of a connection, it will act similarly to an {#identify} in that it
400
- # creates a session on the current connection. Unlike identify however, this packet can also be sent in an existing
401
- # session and will just replay some of the events.
402
- # @param token [String] The token that was used to identify the session to resume.
403
- # @param session_id [String] The session ID of the session to resume.
404
- # @param seq [Integer] The packet sequence of the packet after which the events should be replayed.
405
201
  def send_resume(token, session_id, seq)
406
- data = {
407
- token: token,
408
- session_id: session_id,
409
- seq: seq
410
- }
411
-
412
- send_packet(Opcodes::RESUME, data)
202
+ send_packet(Opcodes::RESUME, { token: token, session_id: session_id, seq: seq })
413
203
  end
414
204
 
415
- # Sends a request members packet (op 8). This will order Discord to gradually sent all requested members as dispatch
416
- # events with type `GUILD_MEMBERS_CHUNK`. It is necessary to use this method in order to get all members of a large
417
- # server (see `large_threshold` in {#send_identify}), however it can also be used for other purposes.
418
- # @param server_id [Integer] The ID of the server whose members to query.
419
- # @param query [String] If this string is not empty, only members whose username starts with this string will be
420
- # returned.
421
- # @param limit [Integer] How many members to send at maximum, or `0` to send all members.
422
205
  def send_request_members(server_id, query, limit)
423
- data = {
424
- guild_id: server_id,
425
- query: query,
426
- limit: limit
427
- }
428
-
429
- send_packet(Opcodes::REQUEST_MEMBERS, data)
206
+ send_packet(Opcodes::REQUEST_MEMBERS, { guild_id: server_id, query: query, limit: limit })
430
207
  end
431
208
 
432
- # Sends a custom packet over the connection. This can be useful to implement future yet unimplemented functionality
433
- # or for testing. You probably shouldn't use this unless you know what you're doing.
434
- # @param opcode [Integer] The opcode the packet should be sent as. Can be one of {Opcodes} or a custom value if
435
- # necessary.
436
- # @param packet [Object] Some arbitrary JSON-serializable data that should be sent as the `d` field.
437
209
  def send_packet(opcode, packet)
438
- data = {
439
- op: opcode,
440
- d: packet
441
- }
442
-
443
- send(data.to_json)
210
+ send({ op: opcode, d: packet }.to_json)
444
211
  end
445
212
 
446
- # Sends custom raw data over the connection. Only useful for testing; even if you know what you're doing you
447
- # probably want to use {#send_packet} instead.
448
- # @param data [String] The data to send.
449
- # @param type [Symbol] The type the WebSocket frame should have; either `:text`, `:binary`, `:ping`, `:pong`, or
450
- # `:close`.
451
- def send_raw(data, type = :text)
452
- send(data, type)
213
+ def send_raw(data, _type = :text)
214
+ send(data)
453
215
  end
454
216
 
455
217
  private
456
218
 
457
219
  def setup_heartbeats(interval)
458
- # Make sure to reset ACK handling, so we don't keep reconnecting
459
220
  @last_heartbeat_acked = true
460
-
461
- # We don't want to have redundant heartbeat threads, so if one already exists, don't start a new one
462
- return if @heartbeat_thread
221
+ return if @heartbeat_task
463
222
 
464
223
  @heartbeat_interval = interval
465
- @heartbeat_thread = Thread.new do
466
- Thread.current[:onyxcord_name] = 'heartbeat'
224
+ @heartbeat_task = @reactor_task&.async do
467
225
  loop do
468
- # Send a heartbeat if heartbeats are active and either no session exists yet, or an existing session is
469
- # suspended (e.g. after op7)
470
226
  if (@session && !@session.suspended?) || !@session
471
227
  sleep @heartbeat_interval
472
- # Check if we're connected here, since we could possibly be waiting for a reconnect to occur.
473
- if @handshaked && !@closed
228
+ if !@closed && @connection
474
229
  @bot.raise_heartbeat_event
475
230
  heartbeat
476
231
  else
477
- LOGGER.debug('Tried to send a heartbeat without being connected! Ignoring, we should be fine.')
232
+ LOGGER.debug('Tried to heartbeat without connection skipping.')
478
233
  end
479
234
  else
480
235
  sleep 1
481
236
  end
482
237
  rescue StandardError => e
483
- LOGGER.error('An error occurred while heartbeating!')
238
+ LOGGER.error('Error while heartbeating!')
484
239
  LOGGER.log_exception(e)
485
240
  end
486
241
  end
487
242
  end
488
243
 
489
244
  def connect_loop
490
- # Initialize falloff so we wait for more time before reconnecting each time
491
245
  @falloff = 1.0
492
-
493
246
  @should_reconnect = true
247
+
494
248
  loop do
495
249
  connect
496
-
497
250
  break unless @should_reconnect
498
251
 
499
252
  if @instant_reconnect
500
- LOGGER.info('Instant reconnection flag was set - reconnecting right away')
253
+ LOGGER.info('Instant reconnection reconnecting now')
501
254
  @instant_reconnect = false
502
255
  else
503
256
  wait_for_reconnect
504
257
  end
505
-
506
- # Restart the loop, i.e. reconnect
507
258
  end
508
259
  end
509
260
 
510
- # Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
511
- # unexpected way
512
261
  def wait_for_reconnect
513
- # We disconnected in an unexpected way! Wait before reconnecting so we don't spam Discord's servers.
514
- LOGGER.debug("Attempting to reconnect in #{@falloff} seconds.")
262
+ LOGGER.debug("Reconnecting in #{@falloff} seconds.")
515
263
  sleep @falloff
516
-
517
- # Calculate new falloff
518
264
  @falloff *= 1.5
519
- @falloff = 115 + (rand * 10) if @falloff > 120 # Cap the falloff at 120 seconds and then add some random jitter
520
- end
521
-
522
- # Create and connect a socket using a URI
523
- def obtain_socket(uri)
524
- socket = TCPSocket.new(uri.host, uri.port || socket_port(uri))
525
-
526
- if secure_uri?(uri)
527
- ctx = OpenSSL::SSL::SSLContext.new
528
-
529
- if ENV['ONYXCORD_SSL_VERIFY_NONE']
530
- ctx.ssl_version = 'SSLv23'
531
- ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
532
-
533
- cert_store = OpenSSL::X509::Store.new
534
- cert_store.set_default_paths
535
- ctx.cert_store = cert_store
536
- else
537
- ctx.set_params ssl_version: :TLSv1_2 # rubocop:disable Naming/VariableNumber
538
- end
539
-
540
- socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
541
- socket.connect
542
- end
543
-
544
- socket
545
- end
546
-
547
- # Whether the URI is secure (connection should be encrypted)
548
- def secure_uri?(uri)
549
- %w[https wss].include? uri.scheme
550
- end
551
-
552
- # The port we should connect to, if the URI doesn't have one set.
553
- def socket_port(uri)
554
- secure_uri?(uri) ? 443 : 80
265
+ @falloff = 115 + (rand * 10) if @falloff > 120
555
266
  end
556
267
 
557
268
  def find_gateway
@@ -561,9 +272,7 @@ module OnyxCord
561
272
 
562
273
  def process_gateway
563
274
  raw_url = @session&.resume_gateway_url || find_gateway
564
-
565
- # Append a slash in case it's not there (I'm not sure how well WSCS handles it otherwise)
566
- raw_url += '/' unless raw_url.end_with? '/'
275
+ raw_url += '/' unless raw_url.end_with?('/')
567
276
 
568
277
  query = if @compress_mode == :stream
569
278
  "?encoding=json&v=#{GATEWAY_VERSION}&compress=zlib-stream"
@@ -577,104 +286,37 @@ module OnyxCord
577
286
  def connect
578
287
  LOGGER.debug('Connecting')
579
288
 
580
- # Get the URI we should connect to
581
289
  url = process_gateway
582
290
  LOGGER.debug("Gateway URL: #{url}")
583
291
 
584
- # Parse it
585
- gateway_uri = URI.parse(url)
586
-
587
- # Zlib context for this gateway connection
588
292
  @zlib_reader = Zlib::Inflate.new
293
+ @pipe_broken = false
294
+ @closed = false
589
295
 
590
- # Connect to the obtained URI with a socket
591
- @socket = obtain_socket(gateway_uri)
592
- LOGGER.debug('Obtained socket')
296
+ endpoint = Async::HTTP::Endpoint.parse(url)
593
297
 
594
- # Initialise some properties
595
- @handshake = ::WebSocket::Handshake::Client.new(url: url) # Represents the handshake between us and the server
596
- @handshaked = false # Whether the handshake has finished yet
597
- @pipe_broken = false # Whether we've received an EPIPE at any time
598
- @closed = false # Whether the websocket is currently closed
298
+ Async::WebSocket::Client.connect(endpoint) do |connection|
299
+ @connection = connection
300
+ LOGGER.debug('WebSocket connected')
599
301
 
600
- # We're done! Delegate to the websocket loop
601
- websocket_loop
602
- rescue StandardError => e
603
- LOGGER.error('An error occurred while connecting to the websocket!')
604
- LOGGER.log_exception(e)
605
- end
606
-
607
- def websocket_loop
608
- # Send the handshake data that we have so far
609
- @socket.write(@handshake.to_s)
302
+ handle_open
610
303
 
611
- # Create a frame to handle received data
612
- frame = ::WebSocket::Frame::Incoming::Client.new
613
-
614
- until @closed
615
- begin
616
- unless @socket
617
- LOGGER.warn('Socket is nil in websocket_loop! Reconnecting')
618
- handle_internal_close('Socket is nil in websocket_loop')
619
- next
620
- end
621
-
622
- # Get some data from the socket
623
- begin
624
- recv_data = @socket.readpartial(4096)
625
- rescue EOFError
626
- @pipe_broken = true
627
- handle_internal_close('Socket EOF in websocket_loop')
628
- next
629
- end
630
-
631
- # Check if we actually got data
632
- unless recv_data
633
- # If we didn't, wait
634
- sleep 1
635
- next
636
- end
637
-
638
- # Check whether the handshake has finished yet
639
- if @handshaked
640
- # If it hasn't, add the received data to the current frame
641
- frame << recv_data
642
-
643
- # Try to parse a message from the frame
644
- msg = frame.next
645
- while msg
646
- # Check whether the message is a close frame, and if it is, handle accordingly
647
- if msg.respond_to?(:code) && msg.code
648
- handle_internal_close(msg)
649
- break
650
- end
651
-
652
- # If there is one, handle it and try again
653
- handle_message(msg.data)
654
- msg = frame.next
655
- end
656
- else
657
- # If the handshake hasn't finished, handle it
658
- handle_handshake_data(recv_data)
659
- end
660
- rescue StandardError => e
661
- handle_error(e)
304
+ while (message = connection.read)
305
+ handle_message(message.to_str)
662
306
  end
663
307
  end
664
- end
665
-
666
- def handle_handshake_data(recv_data)
667
- @handshake << recv_data
668
- return unless @handshake.finished?
669
-
670
- @handshaked = true
671
- handle_open
308
+ rescue StandardError => e
309
+ LOGGER.error('Error connecting to gateway!')
310
+ LOGGER.log_exception(e)
311
+ ensure
312
+ @closed = true
313
+ @connection = nil
672
314
  end
673
315
 
674
316
  def handle_open; end
675
317
 
676
318
  def handle_error(e)
677
- LOGGER.error('An error occurred in the main websocket loop!')
319
+ LOGGER.error('Error in gateway loop!')
678
320
  LOGGER.log_exception(e)
679
321
  end
680
322
 
@@ -684,32 +326,19 @@ module OnyxCord
684
326
  def handle_message(msg)
685
327
  case @compress_mode
686
328
  when :large
687
- if msg.byteslice(0) == 'x'
688
- # The message is compressed, inflate it
689
- msg = Zlib::Inflate.inflate(msg)
690
- end
329
+ msg = Zlib::Inflate.inflate(msg) if msg.byteslice(0) == 'x'
691
330
  when :stream
692
- # Write deflated string to buffer
693
331
  @zlib_reader << msg
694
-
695
- # Check if message ends in `ZLIB_SUFFIX`
696
332
  return if msg.bytesize < 4 || msg.byteslice(-4, 4) != ZLIB_SUFFIX
697
333
 
698
- # Inflate the deflated buffer
699
334
  msg = @zlib_reader.inflate('')
700
335
  end
701
336
 
702
- # Parse packet
703
337
  packet = JSON.parse(msg)
704
338
  op = packet['op'].to_i
705
339
 
706
340
  LOGGER.in(packet)
707
341
 
708
- # If the packet has a sequence defined (all dispatch packets have one), make sure to update that in the
709
- # session so it will be acknowledged next heartbeat.
710
- # Only do this, of course, if a session has been created already; for a READY dispatch (which has s=0 set but is
711
- # the packet that starts the session in the first place) we need not do any handling since initialising the
712
- # session will set it to 0 by default.
713
342
  @session.sequence = packet['s'] if packet['s'] && @session
714
343
 
715
344
  case op
@@ -726,7 +355,7 @@ module OnyxCord
726
355
  when Opcodes::HEARTBEAT
727
356
  handle_heartbeat(packet)
728
357
  else
729
- LOGGER.warn("Received invalid opcode #{op} - please report with this information: #{msg}")
358
+ LOGGER.warn("Invalid opcode #{op}: #{msg}")
730
359
  end
731
360
  end
732
361
 
@@ -737,15 +366,11 @@ module OnyxCord
737
366
 
738
367
  case type
739
368
  when :READY
740
- LOGGER.info("Discord using gateway protocol version: #{data['v']}, requested: #{GATEWAY_VERSION}")
741
-
369
+ LOGGER.info("Discord gateway v#{data['v']}, requested: #{GATEWAY_VERSION}")
742
370
  @session = Session.new(data['session_id'], data['resume_gateway_url'])
743
371
  @session.sequence = 0
744
372
  @bot.__send__(:notify_ready) if @intents && @intents.nobits?(INTENTS[:servers])
745
373
  when :RESUMED
746
- # The RESUMED event is received after a successful op 6 (resume). It does nothing except tell the bot the
747
- # connection is initiated (like READY would). Starting with v5, it doesn't set a new heartbeat interval anymore
748
- # since that is handled by op 10 (HELLO).
749
374
  LOGGER.info 'Resumed'
750
375
  return
751
376
  end
@@ -755,20 +380,18 @@ module OnyxCord
755
380
 
756
381
  # Op 1
757
382
  def handle_heartbeat(packet)
758
- # If we receive a heartbeat, we have to resend one with the same sequence
759
383
  send_heartbeat(packet['s'])
760
384
  end
761
385
 
762
386
  # Op 7
763
387
  def handle_reconnect
764
- LOGGER.debug('Received op 7, reconnecting and attempting resume')
388
+ LOGGER.debug('Received op 7, reconnecting')
765
389
  reconnect
766
390
  end
767
391
 
768
392
  # Op 9
769
393
  def handle_invalidate_session(packet)
770
- LOGGER.debug('Received op 9, invalidating session and re-identifying.')
771
-
394
+ LOGGER.debug('Received op 9, invalidating session')
772
395
  if @session
773
396
  if packet['d'] == true
774
397
  reconnect
@@ -776,28 +399,21 @@ module OnyxCord
776
399
  @session.invalidate
777
400
  end
778
401
  else
779
- LOGGER.warn('Received op 9 without a running session! Not invalidating, we *should* be fine though.')
402
+ LOGGER.warn('Op 9 without session!')
780
403
  end
781
-
782
404
  identify
783
405
  end
784
406
 
785
407
  # Op 10
786
408
  def handle_hello(packet)
787
409
  LOGGER.debug('Hello!')
788
-
789
- # The heartbeat interval is given in ms, so divide it by 1000 to get seconds
790
410
  interval = packet['d']['heartbeat_interval'].to_f / 1000.0
791
411
  setup_heartbeats(interval)
792
-
793
412
  LOGGER.debug("Trace: #{packet['d']['_trace']}")
794
413
  LOGGER.debug("Session: #{@session.inspect}")
795
414
 
796
415
  if @session&.should_resume?
797
- # Make sure we're sending heartbeats again
798
416
  @session.resume
799
-
800
- # Send the actual resume packet to get the missing events
801
417
  resume
802
418
  else
803
419
  identify
@@ -806,11 +422,10 @@ module OnyxCord
806
422
 
807
423
  # Op 11
808
424
  def handle_heartbeat_ack(packet)
809
- LOGGER.debug("Received heartbeat ack for packet: #{packet.inspect}")
425
+ LOGGER.debug("Heartbeat ACK: #{packet.inspect}")
810
426
  @last_heartbeat_acked = true if @check_heartbeat_acks
811
427
  end
812
428
 
813
- # Called when the websocket has been disconnected in some way - say due to a pipe error while sending
814
429
  def handle_internal_close(e)
815
430
  close
816
431
  handle_close(e)
@@ -819,71 +434,45 @@ module OnyxCord
819
434
  def handle_close(e)
820
435
  @bot.__send__(:raise_event, Events::DisconnectEvent.new(@bot))
821
436
 
822
- if e.respond_to? :code
823
- # It is a proper close frame we're dealing with, print reason and message to console
824
- LOGGER.error('Websocket close frame received!')
825
- LOGGER.error("Code: #{e.code}")
826
- LOGGER.error("Message: #{e.data}")
827
-
828
- if e.code == 4014
829
- LOGGER.error(<<~ERROR)
830
- You attempted to identify with privileged intents that your bot is not authorized to use
831
- Please enable the privileged intents on the bot page of your application on the discord developer page.
832
- Read more here https://discord.com/developers/docs/topics/gateway#privileged-intents
833
- ERROR
834
- end
835
-
437
+ if e.respond_to?(:code)
438
+ LOGGER.error("WebSocket close frame! Code: #{e.code}")
439
+ LOGGER.error('Privileged intents not authorized. Enable them in the Discord developer portal.') if e.code == 4014
836
440
  @should_reconnect = false if FATAL_CLOSE_CODES.include?(e.code)
837
- elsif e.is_a? Exception
838
- # Log the exception
839
- LOGGER.error('The websocket connection has closed due to an error!')
441
+ elsif e.is_a?(Exception)
442
+ LOGGER.error('WebSocket closed due to error!')
840
443
  LOGGER.log_exception(e)
841
444
  else
842
- LOGGER.error("The websocket connection has closed: #{e&.inspect || '(no information)'}")
445
+ LOGGER.error("WebSocket closed: #{e&.inspect || '(no info)'}")
843
446
  end
844
447
  end
845
448
 
846
- def send(data, type = :text, code = nil)
449
+ def send(data, _type = :text, _code = nil)
847
450
  LOGGER.out(data)
848
451
 
849
- unless @handshaked && !@closed
850
- # If we're not handshaked or closed, it means there's no connection to send anything to
851
- raise 'Tried to send something to the websocket while not being connected!'
852
- end
853
-
854
- @send_limiter.wait if %i[text binary].include?(type)
452
+ raise 'Tried to send to websocket while not connected!' unless @connection && !@closed
855
453
 
856
- # Create the frame we're going to send
857
- frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version, code: code)
454
+ @send_limiter.wait
858
455
 
859
- # Try to send it
860
- begin
861
- @socket.write frame.to_s
862
- rescue StandardError => e
863
- # There has been an error!
864
- @pipe_broken = true
865
- handle_internal_close(e)
866
- end
456
+ @connection.write(Protocol::WebSocket::TextMessage.generate(data))
457
+ @connection.flush
458
+ rescue StandardError => e
459
+ @pipe_broken = true
460
+ handle_internal_close(e)
867
461
  end
868
462
 
869
- def close(code = 1000)
870
- # If we're already closed, there's no need to do anything - return
463
+ def close(_code = 1000)
871
464
  return if @closed
872
465
 
873
- # Suspend the session so we don't send heartbeats
874
466
  @session&.suspend
875
-
876
- # Send a close frame (if we can)
877
- send nil, :close, code unless @pipe_broken
878
-
879
- # We're officially closed, notify the main loop.
880
467
  @closed = true
881
468
 
882
- # Close the socket if possible
883
- @socket&.close
884
- @socket = nil
469
+ begin
470
+ @connection&.close
471
+ rescue StandardError
472
+ # Ignore close errors
473
+ end
885
474
 
886
- # Make sure we do necessary things as soon as we're closed
475
+ @connection = nil
887
476
  handle_close(nil)
888
477
  end
889
478
  end