discordrb 3.1.1 → 3.4.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of discordrb might be problematic. Click here for more details.

Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +126 -0
  3. data/.codeclimate.yml +16 -0
  4. data/.github/CONTRIBUTING.md +13 -0
  5. data/.github/ISSUE_TEMPLATE/bug_report.md +39 -0
  6. data/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  7. data/.github/pull_request_template.md +37 -0
  8. data/.gitignore +5 -0
  9. data/.rubocop.yml +39 -33
  10. data/.travis.yml +27 -2
  11. data/.yardopts +1 -1
  12. data/CHANGELOG.md +808 -208
  13. data/Gemfile +4 -1
  14. data/LICENSE.txt +1 -1
  15. data/README.md +108 -53
  16. data/Rakefile +14 -1
  17. data/bin/console +1 -0
  18. data/bin/travis_build_docs.sh +17 -0
  19. data/discordrb-webhooks.gemspec +26 -0
  20. data/discordrb.gemspec +24 -15
  21. data/lib/discordrb.rb +75 -2
  22. data/lib/discordrb/allowed_mentions.rb +36 -0
  23. data/lib/discordrb/api.rb +126 -27
  24. data/lib/discordrb/api/channel.rb +165 -43
  25. data/lib/discordrb/api/invite.rb +10 -7
  26. data/lib/discordrb/api/server.rb +240 -61
  27. data/lib/discordrb/api/user.rb +26 -24
  28. data/lib/discordrb/api/webhook.rb +83 -0
  29. data/lib/discordrb/await.rb +1 -2
  30. data/lib/discordrb/bot.rb +417 -149
  31. data/lib/discordrb/cache.rb +42 -10
  32. data/lib/discordrb/colour_rgb.rb +43 -0
  33. data/lib/discordrb/commands/command_bot.rb +186 -31
  34. data/lib/discordrb/commands/container.rb +30 -16
  35. data/lib/discordrb/commands/parser.rb +102 -47
  36. data/lib/discordrb/commands/rate_limiter.rb +18 -17
  37. data/lib/discordrb/container.rb +245 -41
  38. data/lib/discordrb/data.rb +27 -2511
  39. data/lib/discordrb/data/activity.rb +264 -0
  40. data/lib/discordrb/data/application.rb +50 -0
  41. data/lib/discordrb/data/attachment.rb +56 -0
  42. data/lib/discordrb/data/audit_logs.rb +345 -0
  43. data/lib/discordrb/data/channel.rb +849 -0
  44. data/lib/discordrb/data/embed.rb +251 -0
  45. data/lib/discordrb/data/emoji.rb +82 -0
  46. data/lib/discordrb/data/integration.rb +83 -0
  47. data/lib/discordrb/data/invite.rb +137 -0
  48. data/lib/discordrb/data/member.rb +297 -0
  49. data/lib/discordrb/data/message.rb +334 -0
  50. data/lib/discordrb/data/overwrite.rb +102 -0
  51. data/lib/discordrb/data/profile.rb +91 -0
  52. data/lib/discordrb/data/reaction.rb +33 -0
  53. data/lib/discordrb/data/recipient.rb +34 -0
  54. data/lib/discordrb/data/role.rb +191 -0
  55. data/lib/discordrb/data/server.rb +1002 -0
  56. data/lib/discordrb/data/user.rb +204 -0
  57. data/lib/discordrb/data/voice_region.rb +45 -0
  58. data/lib/discordrb/data/voice_state.rb +41 -0
  59. data/lib/discordrb/data/webhook.rb +145 -0
  60. data/lib/discordrb/errors.rb +36 -2
  61. data/lib/discordrb/events/bans.rb +7 -5
  62. data/lib/discordrb/events/channels.rb +2 -0
  63. data/lib/discordrb/events/generic.rb +19 -3
  64. data/lib/discordrb/events/guilds.rb +129 -6
  65. data/lib/discordrb/events/invites.rb +125 -0
  66. data/lib/discordrb/events/members.rb +6 -2
  67. data/lib/discordrb/events/message.rb +86 -36
  68. data/lib/discordrb/events/presence.rb +23 -16
  69. data/lib/discordrb/events/raw.rb +47 -0
  70. data/lib/discordrb/events/reactions.rb +159 -0
  71. data/lib/discordrb/events/roles.rb +7 -6
  72. data/lib/discordrb/events/typing.rb +9 -5
  73. data/lib/discordrb/events/voice_server_update.rb +47 -0
  74. data/lib/discordrb/events/voice_state_update.rb +29 -9
  75. data/lib/discordrb/events/webhooks.rb +64 -0
  76. data/lib/discordrb/gateway.rb +219 -88
  77. data/lib/discordrb/id_object.rb +39 -0
  78. data/lib/discordrb/light.rb +1 -1
  79. data/lib/discordrb/light/integrations.rb +1 -1
  80. data/lib/discordrb/light/light_bot.rb +1 -1
  81. data/lib/discordrb/logger.rb +12 -11
  82. data/lib/discordrb/paginator.rb +57 -0
  83. data/lib/discordrb/permissions.rb +148 -14
  84. data/lib/discordrb/version.rb +1 -1
  85. data/lib/discordrb/voice/encoder.rb +14 -15
  86. data/lib/discordrb/voice/network.rb +86 -45
  87. data/lib/discordrb/voice/sodium.rb +96 -0
  88. data/lib/discordrb/voice/voice_bot.rb +52 -40
  89. data/lib/discordrb/webhooks.rb +12 -0
  90. data/lib/discordrb/websocket.rb +2 -2
  91. metadata +137 -34
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'discordrb/events/generic'
4
+ require 'discordrb/data'
5
+
6
+ module Discordrb::Events
7
+ # Event raised when a webhook is updated
8
+ class WebhookUpdateEvent < Event
9
+ # @return [Server] the server where the webhook updated
10
+ attr_reader :server
11
+
12
+ # @return [Channel] the channel the webhook is associated to
13
+ attr_reader :channel
14
+
15
+ def initialize(data, bot)
16
+ @bot = bot
17
+
18
+ @server = bot.server(data['guild_id'].to_i)
19
+ @channel = bot.channel(data['channel_id'].to_i)
20
+ end
21
+ end
22
+
23
+ # Event handler for {WebhookUpdateEvent}
24
+ class WebhookUpdateEventHandler < EventHandler
25
+ def matches?(event)
26
+ # Check for the proper event type
27
+ return false unless event.is_a? WebhookUpdateEvent
28
+
29
+ [
30
+ matches_all(@attributes[:server], event.server) do |a, e|
31
+ a == case a
32
+ when String
33
+ e.name
34
+ when Integer
35
+ e.id
36
+ else
37
+ e
38
+ end
39
+ end,
40
+ matches_all(@attributes[:channel], event.channel) do |a, e|
41
+ case a
42
+ when String
43
+ # Make sure to remove the "#" from channel names in case it was specified
44
+ a.delete('#') == e.name
45
+ when Integer
46
+ a == e.id
47
+ else
48
+ a == e
49
+ end
50
+ end,
51
+ matches_all(@attributes[:webhook], event) do |a, e|
52
+ a == case a
53
+ when String
54
+ e.name
55
+ when Integer
56
+ e.id
57
+ else
58
+ e
59
+ end
60
+ end
61
+ ].reduce(true, &:&)
62
+ end
63
+ end
64
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # This file uses code from Websocket::Client::Simple, licensed under the following license:
2
4
  #
3
5
  # Copyright (c) 2013-2014 Sho Hashimoto
@@ -23,8 +25,6 @@
23
25
  # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24
26
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
27
 
26
- require 'thread'
27
-
28
28
  module Discordrb
29
29
  # Gateway packet opcodes
30
30
  module Opcodes
@@ -72,8 +72,9 @@ module Discordrb
72
72
  # members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
73
73
  REQUEST_MEMBERS = 8
74
74
 
75
- # **Received**: The functionality of this opcode is less known than the others but it appears to specifically
76
- # tell the client to invalidate its local session and continue by {IDENTIFY}ing.
75
+ # **Received**: Sent by Discord when the session becomes invalid for any reason. This may include improperly
76
+ # resuming existing sessions, attempting to start sessions with invalid data, or something else entirely. The client
77
+ # should handle this by simply starting a new session.
77
78
  INVALIDATE_SESSION = 9
78
79
 
79
80
  # **Received**: Sent immediately for any opened connection; tells the client to start heartbeating early on, so the
@@ -93,12 +94,13 @@ module Discordrb
93
94
  attr_accessor :sequence
94
95
 
95
96
  def initialize(session_id)
96
- @id = session_id
97
+ @session_id = session_id
97
98
  @sequence = 0
98
99
  @suspended = false
99
100
  @invalid = false
100
101
  end
101
102
 
103
+ # Flags this session as suspended, so we know not to try and send heartbeats, etc. to the gateway until we've reconnected
102
104
  def suspend
103
105
  @suspended = true
104
106
  end
@@ -107,6 +109,12 @@ module Discordrb
107
109
  @suspended
108
110
  end
109
111
 
112
+ # Flags this session as no longer being suspended, so we can resume
113
+ def resume
114
+ @suspended = false
115
+ end
116
+
117
+ # Flags this session as being invalid
110
118
  def invalidate
111
119
  @invalid = true
112
120
  end
@@ -128,14 +136,26 @@ module Discordrb
128
136
  # The version of the gateway that's supposed to be used.
129
137
  GATEWAY_VERSION = 6
130
138
 
131
- def initialize(bot, token)
139
+ # Heartbeat ACKs are Discord's way of verifying on the client side whether the connection is still alive. If this is
140
+ # set to true (default value) the gateway client will use that functionality to detect zombie connections and
141
+ # reconnect in such a case; however it may lead to instability if there's some problem with the ACKs. If this occurs
142
+ # it can simply be set to false.
143
+ # @return [true, false] whether or not this gateway should check for heartbeat ACKs.
144
+ attr_accessor :check_heartbeat_acks
145
+
146
+ def initialize(bot, token, shard_key = nil, compress_mode = :stream, intents = nil)
132
147
  @token = token
133
148
  @bot = bot
134
149
 
135
- @getc_mutex = Mutex.new
150
+ @shard_key = shard_key
136
151
 
137
152
  # Whether the connection to the gateway has succeeded yet
138
153
  @ws_success = false
154
+
155
+ @check_heartbeat_acks = true
156
+
157
+ @compress_mode = compress_mode
158
+ @intents = intents
139
159
  end
140
160
 
141
161
  # Connect to the gateway server in a separate thread
@@ -147,27 +167,41 @@ module Discordrb
147
167
  end
148
168
 
149
169
  LOGGER.debug('WS thread created! Now waiting for confirmation that everything worked')
150
- sleep(0.5) until @ws_success
151
- LOGGER.debug('Confirmation received! Exiting run.')
170
+ loop do
171
+ sleep(0.5)
172
+
173
+ if @ws_success
174
+ LOGGER.debug('Confirmation received! Exiting run.')
175
+ break
176
+ end
177
+
178
+ if @should_reconnect == false
179
+ LOGGER.debug('Reconnection flag was unset. Exiting run.')
180
+ break
181
+ end
182
+ end
152
183
  end
153
184
 
154
- # Prevents all further execution until the websocket thread stops (e. g. through a closed connection).
185
+ # Prevents all further execution until the websocket thread stops (e.g. through a closed connection).
155
186
  def sync
156
187
  @ws_thread.join
157
188
  end
158
189
 
159
190
  # Whether the WebSocket connection to the gateway is currently open
160
191
  def open?
161
- @handshake && @handshake.finished? && !@closed
192
+ @handshake&.finished? && !@closed
162
193
  end
163
194
 
164
195
  # Stops the bot gracefully, disconnecting the websocket without immediately killing the thread. This means that
165
196
  # Discord is immediately aware of the closed connection and makes the bot appear offline instantly.
166
197
  #
167
198
  # If this method doesn't work or you're looking for something more drastic, use {#kill} instead.
168
- def stop(no_sync = false)
199
+ def stop
169
200
  @should_reconnect = false
170
- close(no_sync)
201
+ close
202
+
203
+ # Return nil so command bots don't send a message
204
+ nil
171
205
  end
172
206
 
173
207
  # Kills the websocket thread, stopping all connections to Discord.
@@ -213,6 +247,21 @@ module Discordrb
213
247
  # before it), or if none have been received yet, with 0.
214
248
  # @see #send_heartbeat
215
249
  def heartbeat
250
+ if check_heartbeat_acks
251
+ unless @last_heartbeat_acked
252
+ # We're in a bad situation - apparently the last heartbeat wasn't ACK'd, which means the connection is likely
253
+ # a zombie. Reconnect
254
+ LOGGER.warn('Last heartbeat was not acked, so this is a zombie connection! Reconnecting')
255
+
256
+ # We can't send anything on zombie connections
257
+ @pipe_broken = true
258
+ reconnect
259
+ return
260
+ end
261
+
262
+ @last_heartbeat_acked = false
263
+ end
264
+
216
265
  send_heartbeat(@session ? @session.sequence : 0)
217
266
  end
218
267
 
@@ -226,13 +275,14 @@ module Discordrb
226
275
  # Identifies to Discord with the default parameters.
227
276
  # @see #send_identify
228
277
  def identify
278
+ compress = @compress_mode == :large
229
279
  send_identify(@token, {
230
- :'$os' => RUBY_PLATFORM,
231
- :'$browser' => 'discordrb',
232
- :'$device' => 'discordrb',
233
- :'$referrer' => '',
234
- :'$referring_domain' => ''
235
- }, true, 100)
280
+ '$os': RUBY_PLATFORM,
281
+ '$browser': 'discordrb',
282
+ '$device': 'discordrb',
283
+ '$referrer': '',
284
+ '$referring_domain': ''
285
+ }, compress, 100, @shard_key)
236
286
  end
237
287
 
238
288
  # Sends an identify packet (op 2). This starts a new session on the current connection and tells Discord who we are.
@@ -251,7 +301,9 @@ module Discordrb
251
301
  # @param compress [true, false] Whether certain large packets should be compressed using zlib.
252
302
  # @param large_threshold [Integer] The member threshold after which a server counts as large and will have to have
253
303
  # its member list chunked.
254
- def send_identify(token, properties, compress, large_threshold)
304
+ # @param shard_key [Array(Integer, Integer), nil] The shard key to use for sharding, represented as
305
+ # [shard_id, num_shards], or nil if the bot should not be sharded.
306
+ def send_identify(token, properties, compress, large_threshold, shard_key = nil)
255
307
  data = {
256
308
  # Don't send a v anymore as it's entirely determined by the URL now
257
309
  token: token,
@@ -259,13 +311,17 @@ module Discordrb
259
311
  compress: compress,
260
312
  large_threshold: large_threshold
261
313
  }
314
+ data[:intents] = @intents unless @intents.nil?
315
+
316
+ # Don't include the shard key at all if it is nil as Discord checks for its mere existence
317
+ data[:shard] = shard_key if shard_key
262
318
 
263
319
  send_packet(Opcodes::IDENTIFY, data)
264
320
  end
265
321
 
266
322
  # Sends a status update packet (op 3). This sets the bot user's status (online/idle/...) and game playing/streaming.
267
323
  # @param status [String] The status that should be set (`online`, `idle`, `dnd`, `invisible`).
268
- # @param since [Integer] The unix timestamp in milliseconds when the status was set. Should only be provided when
324
+ # @param since [Integer] The Unix timestamp in milliseconds when the status was set. Should only be provided when
269
325
  # `afk` is true.
270
326
  # @param game [Hash<Symbol => Object>, nil] `nil` if no game should be played, or a hash of `:game => "name"` if a
271
327
  # game should be played. The hash can also contain additional attributes for streaming statuses.
@@ -305,6 +361,17 @@ module Discordrb
305
361
  send_resume(@token, @session.session_id, @session.sequence)
306
362
  end
307
363
 
364
+ # Reconnects the gateway connection in a controlled manner.
365
+ # @param attempt_resume [true, false] Whether a resume should be attempted after the reconnection.
366
+ def reconnect(attempt_resume = true)
367
+ @session.suspend if @session && attempt_resume
368
+
369
+ @instant_reconnect = true
370
+ @should_reconnect = true
371
+
372
+ close(4000)
373
+ end
374
+
308
375
  # Sends a resume packet (op 6). This replays all events from a previous point specified by its packet sequence. This
309
376
  # will not work if the packet to resume from has already been acknowledged using a heartbeat, or if the session ID
310
377
  # belongs to a now invalid session.
@@ -342,9 +409,35 @@ module Discordrb
342
409
  send_packet(Opcodes::REQUEST_MEMBERS, data)
343
410
  end
344
411
 
412
+ # Sends a custom packet over the connection. This can be useful to implement future yet unimplemented functionality
413
+ # or for testing. You probably shouldn't use this unless you know what you're doing.
414
+ # @param opcode [Integer] The opcode the packet should be sent as. Can be one of {Opcodes} or a custom value if
415
+ # necessary.
416
+ # @param packet [Object] Some arbitrary JSON-serializable data that should be sent as the `d` field.
417
+ def send_packet(opcode, packet)
418
+ data = {
419
+ op: opcode,
420
+ d: packet
421
+ }
422
+
423
+ send(data.to_json)
424
+ end
425
+
426
+ # Sends custom raw data over the connection. Only useful for testing; even if you know what you're doing you
427
+ # probably want to use {#send_packet} instead.
428
+ # @param data [String] The data to send.
429
+ # @param type [Symbol] The type the WebSocket frame should have; either `:text`, `:binary`, `:ping`, `:pong`, or
430
+ # `:close`.
431
+ def send_raw(data, type = :text)
432
+ send(data, type)
433
+ end
434
+
345
435
  private
346
436
 
347
437
  def setup_heartbeats(interval)
438
+ # Make sure to reset ACK handling, so we don't keep reconnecting
439
+ @last_heartbeat_acked = true
440
+
348
441
  # We don't want to have redundant heartbeat threads, so if one already exists, don't start a new one
349
442
  return if @heartbeat_thread
350
443
 
@@ -352,20 +445,18 @@ module Discordrb
352
445
  @heartbeat_thread = Thread.new do
353
446
  Thread.current[:discordrb_name] = 'heartbeat'
354
447
  loop do
355
- begin
356
- # Send a heartbeat if heartbeats are active and either no session exists yet, or an existing session is
357
- # suspended (e.g. after op7)
358
- if (@session && !@session.suspended?) || !@session
359
- sleep @heartbeat_interval
360
- @bot.raise_heartbeat_event
361
- heartbeat
362
- else
363
- sleep 1
364
- end
365
- rescue => e
366
- LOGGER.error('An error occurred while heartbeating!')
367
- LOGGER.log_exception(e)
448
+ # Send a heartbeat if heartbeats are active and either no session exists yet, or an existing session is
449
+ # suspended (e.g. after op7)
450
+ if (@session && !@session.suspended?) || !@session
451
+ sleep @heartbeat_interval
452
+ @bot.raise_heartbeat_event
453
+ heartbeat
454
+ else
455
+ sleep 1
368
456
  end
457
+ rescue StandardError => e
458
+ LOGGER.error('An error occurred while heartbeating!')
459
+ LOGGER.log_exception(e)
369
460
  end
370
461
  end
371
462
  end
@@ -381,14 +472,13 @@ module Discordrb
381
472
  break unless @should_reconnect
382
473
 
383
474
  if @instant_reconnect
384
- # We got an op 7! Don't wait before reconnecting
385
- LOGGER.info('Got an op 7, reconnecting right away')
475
+ LOGGER.info('Instant reconnection flag was set - reconnecting right away')
386
476
  @instant_reconnect = false
387
477
  else
388
478
  wait_for_reconnect
389
479
  end
390
480
 
391
- # Restart the loop, i. e. reconnect
481
+ # Restart the loop, i.e. reconnect
392
482
  end
393
483
  end
394
484
 
@@ -410,14 +500,19 @@ module Discordrb
410
500
 
411
501
  if secure_uri?(uri)
412
502
  ctx = OpenSSL::SSL::SSLContext.new
413
- ctx.ssl_version = 'SSLv23'
414
- ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
415
503
 
416
- cert_store = OpenSSL::X509::Store.new
417
- cert_store.set_default_paths
418
- ctx.cert_store = cert_store
504
+ if ENV['DISCORDRB_SSL_VERIFY_NONE']
505
+ ctx.ssl_version = 'SSLv23'
506
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
507
+
508
+ cert_store = OpenSSL::X509::Store.new
509
+ cert_store.set_default_paths
510
+ ctx.cert_store = cert_store
511
+ else
512
+ ctx.set_params ssl_version: :TLSv1_2 # rubocop:disable Naming/VariableNumber
513
+ end
419
514
 
420
- socket = ::OpenSSL::SSL::SSLSocket.new(socket, ctx)
515
+ socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
421
516
  socket.connect
422
517
  end
423
518
 
@@ -426,7 +521,7 @@ module Discordrb
426
521
 
427
522
  # Whether the URI is secure (connection should be encrypted)
428
523
  def secure_uri?(uri)
429
- %w(https wss).include? uri.scheme
524
+ %w[https wss].include? uri.scheme
430
525
  end
431
526
 
432
527
  # The port we should connect to, if the URI doesn't have one set.
@@ -445,8 +540,13 @@ module Discordrb
445
540
  # Append a slash in case it's not there (I'm not sure how well WSCS handles it otherwise)
446
541
  raw_url += '/' unless raw_url.end_with? '/'
447
542
 
448
- # Add the parameters we want
449
- raw_url + "?encoding=json&v=#{GATEWAY_VERSION}"
543
+ query = if @compress_mode == :stream
544
+ "?encoding=json&v=#{GATEWAY_VERSION}&compress=zlib-stream"
545
+ else
546
+ "?encoding=json&v=#{GATEWAY_VERSION}"
547
+ end
548
+
549
+ raw_url + query
450
550
  end
451
551
 
452
552
  def connect
@@ -459,6 +559,9 @@ module Discordrb
459
559
  # Parse it
460
560
  gateway_uri = URI.parse(url)
461
561
 
562
+ # Zlib context for this gateway connection
563
+ @zlib_reader = Zlib::Inflate.new
564
+
462
565
  # Connect to the obtained URI with a socket
463
566
  @socket = obtain_socket(gateway_uri)
464
567
  LOGGER.debug('Obtained socket')
@@ -471,7 +574,7 @@ module Discordrb
471
574
 
472
575
  # We're done! Delegate to the websocket loop
473
576
  websocket_loop
474
- rescue => e
577
+ rescue StandardError => e
475
578
  LOGGER.error('An error occurred while connecting to the websocket!')
476
579
  LOGGER.log_exception(e)
477
580
  end
@@ -485,10 +588,20 @@ module Discordrb
485
588
 
486
589
  until @closed
487
590
  begin
488
- recv_data = nil
591
+ unless @socket
592
+ LOGGER.warn('Socket is nil in websocket_loop! Reconnecting')
593
+ handle_internal_close('Socket is nil in websocket_loop')
594
+ next
595
+ end
489
596
 
490
- # Get some data from the socket, synchronised so the socket can't be closed during this
491
- @getc_mutex.synchronize { recv_data = @socket.getc }
597
+ # Get some data from the socket
598
+ begin
599
+ recv_data = @socket.readpartial(4096)
600
+ rescue EOFError
601
+ @pipe_broken = true
602
+ handle_internal_close('Socket EOF in websocket_loop')
603
+ next
604
+ end
492
605
 
493
606
  # Check if we actually got data
494
607
  unless recv_data
@@ -519,7 +632,7 @@ module Discordrb
519
632
  # If the handshake hasn't finished, handle it
520
633
  handle_handshake_data(recv_data)
521
634
  end
522
- rescue => e
635
+ rescue StandardError => e
523
636
  handle_error(e)
524
637
  end
525
638
  end
@@ -533,18 +646,31 @@ module Discordrb
533
646
  handle_open
534
647
  end
535
648
 
536
- def handle_open
537
- end
649
+ def handle_open; end
538
650
 
539
651
  def handle_error(e)
540
652
  LOGGER.error('An error occurred in the main websocket loop!')
541
653
  LOGGER.log_exception(e)
542
654
  end
543
655
 
656
+ ZLIB_SUFFIX = "\x00\x00\xFF\xFF".b.freeze
657
+
544
658
  def handle_message(msg)
545
- if msg.byteslice(0) == 'x'
546
- # The message is compressed, inflate it
547
- msg = Zlib::Inflate.inflate(msg)
659
+ case @compress_mode
660
+ when :large
661
+ if msg.byteslice(0) == 'x'
662
+ # The message is compressed, inflate it
663
+ msg = Zlib::Inflate.inflate(msg)
664
+ end
665
+ when :stream
666
+ # Write deflated string to buffer
667
+ @zlib_reader << msg
668
+
669
+ # Check if message ends in `ZLIB_SUFFIX`
670
+ return if msg.bytesize < 4 || msg.byteslice(-4, 4) != ZLIB_SUFFIX
671
+
672
+ # Inflate the deflated buffer
673
+ msg = @zlib_reader.inflate('')
548
674
  end
549
675
 
550
676
  # Parse packet
@@ -571,6 +697,8 @@ module Discordrb
571
697
  handle_invalidate_session
572
698
  when Opcodes::HEARTBEAT_ACK
573
699
  handle_heartbeat_ack(packet)
700
+ when Opcodes::HEARTBEAT
701
+ handle_heartbeat(packet)
574
702
  else
575
703
  LOGGER.warn("Received invalid opcode #{op} - please report with this information: #{msg}")
576
704
  end
@@ -587,24 +715,28 @@ module Discordrb
587
715
 
588
716
  @session = Session.new(data['session_id'])
589
717
  @session.sequence = 0
718
+ @bot.__send__(:notify_ready) if @intents && (@intents & INTENTS[:servers]).zero?
590
719
  when :RESUMED
591
720
  # The RESUMED event is received after a successful op 6 (resume). It does nothing except tell the bot the
592
721
  # connection is initiated (like READY would). Starting with v5, it doesn't set a new heartbeat interval anymore
593
722
  # since that is handled by op 10 (HELLO).
594
- LOGGER.debug('Connection resumed')
723
+ LOGGER.info 'Resumed'
595
724
  return
596
725
  end
597
726
 
598
727
  @bot.dispatch(type, data)
599
728
  end
600
729
 
730
+ # Op 1
731
+ def handle_heartbeat(packet)
732
+ # If we receive a heartbeat, we have to resend one with the same sequence
733
+ send_heartbeat(packet['s'])
734
+ end
735
+
601
736
  # Op 7
602
737
  def handle_reconnect
603
- @instant_reconnect = true
604
- close
605
-
606
- # Suspend session so we resume afterwards
607
- @session.suspend
738
+ LOGGER.debug('Received op 7, reconnecting and attempting resume')
739
+ reconnect
608
740
  end
609
741
 
610
742
  # Op 9
@@ -629,8 +761,13 @@ module Discordrb
629
761
  setup_heartbeats(interval)
630
762
 
631
763
  LOGGER.debug("Trace: #{packet['d']['_trace']}")
764
+ LOGGER.debug("Session: #{@session.inspect}")
765
+
766
+ if @session&.should_resume?
767
+ # Make sure we're sending heartbeats again
768
+ @session.resume
632
769
 
633
- if @session && @session.should_resume?
770
+ # Send the actual resume packet to get the missing events
634
771
  resume
635
772
  else
636
773
  identify
@@ -640,6 +777,7 @@ module Discordrb
640
777
  # Op 11
641
778
  def handle_heartbeat_ack(packet)
642
779
  LOGGER.debug("Received heartbeat ack for packet: #{packet.inspect}")
780
+ @last_heartbeat_acked = true if @check_heartbeat_acks
643
781
  end
644
782
 
645
783
  # Called when the websocket has been disconnected in some way - say due to a pipe error while sending
@@ -648,31 +786,31 @@ module Discordrb
648
786
  handle_close(e)
649
787
  end
650
788
 
789
+ # Close codes that are unrecoverable, after which we should not try to reconnect.
790
+ # - 4003: Not authenticated. How did this happen?
791
+ # - 4004: Authentication failed. Token was wrong, nothing we can do.
792
+ # - 4011: Sharding required. Currently requires developer intervention.
793
+ FATAL_CLOSE_CODES = [4003, 4004, 4011].freeze
794
+
651
795
  def handle_close(e)
796
+ @bot.__send__(:raise_event, Events::DisconnectEvent.new(@bot))
797
+
652
798
  if e.respond_to? :code
653
799
  # It is a proper close frame we're dealing with, print reason and message to console
654
800
  LOGGER.error('Websocket close frame received!')
655
801
  LOGGER.error("Code: #{e.code}")
656
802
  LOGGER.error("Message: #{e.data}")
803
+ @should_reconnect = false if FATAL_CLOSE_CODES.include?(e.code)
657
804
  elsif e.is_a? Exception
658
805
  # Log the exception
659
806
  LOGGER.error('The websocket connection has closed due to an error!')
660
807
  LOGGER.log_exception(e)
661
808
  else
662
- LOGGER.error("The websocket connection has closed: #{e.inspect}")
809
+ LOGGER.error("The websocket connection has closed: #{e&.inspect || '(no information)'}")
663
810
  end
664
811
  end
665
812
 
666
- def send_packet(op, packet)
667
- data = {
668
- op: op,
669
- d: packet
670
- }
671
-
672
- send(data.to_json)
673
- end
674
-
675
- def send(data, type = :text)
813
+ def send(data, type = :text, code = nil)
676
814
  LOGGER.out(data)
677
815
 
678
816
  unless @handshaked && !@closed
@@ -681,40 +819,33 @@ module Discordrb
681
819
  end
682
820
 
683
821
  # Create the frame we're going to send
684
- frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version)
822
+ frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version, code: code)
685
823
 
686
824
  # Try to send it
687
825
  begin
688
826
  @socket.write frame.to_s
689
- rescue Errno::EPIPE => e
827
+ rescue StandardError => e
690
828
  # There has been an error!
691
829
  @pipe_broken = true
692
830
  handle_internal_close(e)
693
831
  end
694
832
  end
695
833
 
696
- def close(no_sync = false)
834
+ def close(code = 1000)
697
835
  # If we're already closed, there's no need to do anything - return
698
836
  return if @closed
699
837
 
700
838
  # Suspend the session so we don't send heartbeats
701
- @session.suspend if @session
839
+ @session&.suspend
702
840
 
703
841
  # Send a close frame (if we can)
704
- send nil, :close unless @pipe_broken
842
+ send nil, :close, code unless @pipe_broken
705
843
 
706
844
  # We're officially closed, notify the main loop.
707
- # This needs to be synchronised with the getc mutex, so the notification, and especially the actual
708
- # close afterwards, don't coincide with the main loop reading something from the SSL socket.
709
- # This would cause a segfault due to (I suspect) Ruby bug #12292: https://bugs.ruby-lang.org/issues/12292
710
- if no_sync
711
- @closed = true
712
- else
713
- @getc_mutex.synchronize { @closed = true }
714
- end
845
+ @closed = true
715
846
 
716
847
  # Close the socket if possible
717
- @socket.close if @socket
848
+ @socket&.close
718
849
  @socket = nil
719
850
 
720
851
  # Make sure we do necessary things as soon as we're closed