discordrb 2.1.3 → 3.0.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.

@@ -62,12 +62,20 @@ module Discordrb::Events
62
62
  # @return [String] the new game the user is playing.
63
63
  attr_reader :game
64
64
 
65
+ # @return [String] the URL to the stream
66
+ attr_reader :url
67
+
68
+ # @return [Integer] the type of play. 0 = game, 1 = Twitch
69
+ attr_reader :type
70
+
65
71
  def initialize(data, bot)
66
72
  @bot = bot
67
73
 
68
74
  @user = bot.user(data['user']['id'].to_i)
69
75
  @game = data['game'] ? data['game']['name'] : nil
70
76
  @server = bot.server(data['guild_id'].to_i)
77
+ @url = data['game'] ? data['game']['url'] : nil
78
+ @type = data['game'] ? data['game']['type'].to_i : nil
71
79
  end
72
80
  end
73
81
 
@@ -93,6 +101,13 @@ module Discordrb::Events
93
101
  else
94
102
  e
95
103
  end
104
+ end,
105
+ matches_all(@attributes[:type], event.type) do |a, e|
106
+ a == if a.is_a? Integer
107
+ e.type
108
+ else
109
+ e
110
+ end
96
111
  end
97
112
  ].reduce(true, &:&)
98
113
  end
@@ -23,7 +23,13 @@ module Discordrb::Events
23
23
  @channel_id = data['channel_id'].to_i
24
24
  @channel = bot.channel(@channel_id)
25
25
 
26
- @user = channel.private? ? channel.recipient : bot.member(@channel.server.id, @user_id)
26
+ @user = if channel.pm?
27
+ channel.recipient
28
+ elsif channel.group?
29
+ bot.user(@user_id)
30
+ else
31
+ bot.member(@channel.server.id, @user_id)
32
+ end
27
33
 
28
34
  @timestamp = Time.at(data['timestamp'].to_i)
29
35
  end
@@ -0,0 +1,724 @@
1
+ # This file uses code from Websocket::Client::Simple, licensed under the following license:
2
+ #
3
+ # Copyright (c) 2013-2014 Sho Hashimoto
4
+ #
5
+ # MIT License
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining
8
+ # a copy of this software and associated documentation files (the
9
+ # "Software"), to deal in the Software without restriction, including
10
+ # without limitation the rights to use, copy, modify, merge, publish,
11
+ # distribute, sublicense, and/or sell copies of the Software, and to
12
+ # permit persons to whom the Software is furnished to do so, subject to
13
+ # the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be
16
+ # included in all copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ require 'thread'
27
+
28
+ module Discordrb
29
+ # Gateway packet opcodes
30
+ module Opcodes
31
+ # **Received** when Discord dispatches an event to the gateway (like MESSAGE_CREATE, PRESENCE_UPDATE or whatever).
32
+ # The vast majority of received packets will have this opcode.
33
+ DISPATCH = 0
34
+
35
+ # **Two-way**: The client has to send a packet with this opcode every ~40 seconds (actual interval specified in
36
+ # READY or RESUMED) and the current sequence number, otherwise it will be disconnected from the gateway. In certain
37
+ # cases Discord may also send one, specifically if two clients are connected at once.
38
+ HEARTBEAT = 1
39
+
40
+ # **Sent**: This is one of the two possible ways to initiate a session after connecting to the gateway. It
41
+ # should contain the authentication token along with other stuff the server has to know right from the start, such
42
+ # as large_threshold and, for older gateway versions, the desired version.
43
+ IDENTIFY = 2
44
+
45
+ # **Sent**: Packets with this opcode are used to change the user's status and played game. (Sending this is never
46
+ # necessary for a gateway client to behave correctly)
47
+ PRESENCE = 3
48
+
49
+ # **Sent**: Packets with this opcode are used to change a user's voice state (mute/deaf/unmute/undeaf/etc.). It is
50
+ # also used to connect to a voice server in the first place. (Sending this is never necessary for a gateway client
51
+ # to behave correctly)
52
+ VOICE_STATE = 4
53
+
54
+ # **Sent**: This opcode is used to ping a voice server, whatever that means. The functionality of this opcode isn't
55
+ # known well but non-user clients should never send it.
56
+ VOICE_PING = 5
57
+
58
+ # **Sent**: This is the other of two possible ways to initiate a gateway session (other than {IDENTIFY}). Rather
59
+ # than starting an entirely new session, it resumes an existing session by replaying all events from a given
60
+ # sequence number. It should be used to recover from a connection error or anything like that when the session is
61
+ # still valid - sending this with an invalid session will cause an error to occur.
62
+ RESUME = 6
63
+
64
+ # **Received**: Discord sends this opcode to indicate that the client should reconnect to a different gateway
65
+ # server because the old one is currently being decommissioned. Counterintuitively, this opcode also invalidates the
66
+ # session - the client has to create an entirely new session with the new gateway instead of resuming the old one.
67
+ RECONNECT = 7
68
+
69
+ # **Sent**: This opcode identifies packets used to retrieve a list of members from a particular server. There is
70
+ # also a REST endpoint available for this, but it is inconvenient to use because the client has to implement
71
+ # pagination itself, whereas sending this opcode lets Discord handle the pagination and the client can just add
72
+ # members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
73
+ REQUEST_MEMBERS = 8
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.
77
+ INVALIDATE_SESSION = 9
78
+
79
+ # **Received**: Sent immediately for any opened connection; tells the client to start heartbeating early on, so the
80
+ # server can safely search for a session server to handle the connection without the connection being terminated.
81
+ # As a side-effect, large bots are less likely to disconnect because of very large READY parse times.
82
+ HELLO = 10
83
+
84
+ # **Received**: Returned after a heartbeat was sent to the server. This allows clients to identify and deal with
85
+ # zombie connections that don't dispatch any events anymore.
86
+ HEARTBEAT_ACK = 11
87
+ end
88
+
89
+ # This class stores the data of an active gateway session. Note that this is different from a websocket connection -
90
+ # there may be multiple sessions per connection or one session may persist over multiple connections.
91
+ class Session
92
+ attr_reader :session_id
93
+ attr_accessor :sequence
94
+
95
+ def initialize(session_id)
96
+ @id = session_id
97
+ @sequence = 0
98
+ @suspended = false
99
+ @invalid = false
100
+ end
101
+
102
+ def suspend
103
+ @suspended = true
104
+ end
105
+
106
+ def suspended?
107
+ @suspended
108
+ end
109
+
110
+ def invalidate
111
+ @invalid = true
112
+ end
113
+
114
+ def invalid?
115
+ @invalid
116
+ end
117
+
118
+ def should_resume?
119
+ suspended? && !invalid?
120
+ end
121
+ end
122
+
123
+ # Client for the Discord gateway protocol
124
+ class Gateway
125
+ # How many members there need to be in a server for it to count as "large"
126
+ LARGE_THRESHOLD = 100
127
+
128
+ # The version of the gateway that's supposed to be used.
129
+ GATEWAY_VERSION = 6
130
+
131
+ def initialize(bot, token)
132
+ @token = token
133
+ @bot = bot
134
+
135
+ @getc_mutex = Mutex.new
136
+
137
+ # Whether the connection to the gateway has succeeded yet
138
+ @ws_success = false
139
+ end
140
+
141
+ # Connect to the gateway server in a separate thread
142
+ def run_async
143
+ @ws_thread = Thread.new do
144
+ Thread.current[:discordrb_name] = 'websocket'
145
+ connect_loop
146
+ LOGGER.warn('The WS loop exited! Not sure if this is a good thing')
147
+ end
148
+
149
+ 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.')
152
+ end
153
+
154
+ # Prevents all further execution until the websocket thread stops (e. g. through a closed connection).
155
+ def sync
156
+ @ws_thread.join
157
+ end
158
+
159
+ # Whether the WebSocket connection to the gateway is currently open
160
+ def open?
161
+ @handshake && @handshake.finished? && !@closed
162
+ end
163
+
164
+ # Stops the bot gracefully, disconnecting the websocket without immediately killing the thread. This means that
165
+ # Discord is immediately aware of the closed connection and makes the bot appear offline instantly.
166
+ #
167
+ # If this method doesn't work or you're looking for something more drastic, use {#kill} instead.
168
+ def stop(no_sync = false)
169
+ @should_reconnect = false
170
+ close(no_sync)
171
+ end
172
+
173
+ # Kills the websocket thread, stopping all connections to Discord.
174
+ def kill
175
+ @ws_thread.kill
176
+ end
177
+
178
+ # Notifies the {#run_async} method that everything is ready and the caller can now continue (i.e. with syncing,
179
+ # or with doing processing and then syncing)
180
+ def notify_ready
181
+ @ws_success = true
182
+ end
183
+
184
+ # Injects a reconnect event (op 7) into the event processor, causing Discord to reconnect to the given gateway URL.
185
+ # If the URL is set to nil, it will reconnect and get an entirely new gateway URL. This method has not much use
186
+ # outside of testing and implementing highly custom reconnect logic.
187
+ # @param url [String, nil] the URL to connect to or nil if one should be obtained from Discord.
188
+ def inject_reconnect(url = nil)
189
+ # When no URL is specified, the data should be nil, as is the case with Discord-sent packets.
190
+ data = url ? { url: url } : nil
191
+
192
+ handle_message({
193
+ op: Opcodes::RECONNECT,
194
+ d: data
195
+ }.to_json)
196
+ end
197
+
198
+ # Injects a resume packet (op 6) into the gateway. If this is done with a running connection, it will cause an
199
+ # error. It has no use outside of testing stuff that I know of, but if you want to use it anyway for some reason,
200
+ # here it is.
201
+ # @param seq [Integer, nil] The sequence ID to inject, or nil if the currently tracked one should be used.
202
+ def inject_resume(seq)
203
+ send_resume(raw_token, @session_id, seq || @sequence)
204
+ end
205
+
206
+ # Injects a terminal gateway error into the handler. Useful for testing the reconnect logic.
207
+ # @param e [Exception] The exception object to inject.
208
+ def inject_error(e)
209
+ handle_internal_close(e)
210
+ end
211
+
212
+ # Sends a heartbeat with the last received packet's seq (to acknowledge that we have received it and all packets
213
+ # before it), or if none have been received yet, with 0.
214
+ # @see #send_heartbeat
215
+ def heartbeat
216
+ send_heartbeat(@session ? @session.sequence : 0)
217
+ end
218
+
219
+ # Sends a heartbeat packet (op 1). This tells Discord that the current connection is still active and that the last
220
+ # packets until the given sequence have been processed (in case of a resume).
221
+ # @param sequence [Integer] The sequence number for which to send a heartbeat.
222
+ def send_heartbeat(sequence)
223
+ send_packet(Opcodes::HEARTBEAT, sequence)
224
+ end
225
+
226
+ # Identifies to Discord with the default parameters.
227
+ # @see #send_identify
228
+ def identify
229
+ send_identify(@token, {
230
+ :'$os' => RUBY_PLATFORM,
231
+ :'$browser' => 'discordrb',
232
+ :'$device' => 'discordrb',
233
+ :'$referrer' => '',
234
+ :'$referring_domain' => ''
235
+ }, true, 100)
236
+ end
237
+
238
+ # Sends an identify packet (op 2). This starts a new session on the current connection and tells Discord who we are.
239
+ # This can only be done once a connection.
240
+ # @param token [String] The token with which to authorise the session. If it belongs to a bot account, it must be
241
+ # prefixed with "Bot ".
242
+ # @param properties [Hash<Symbol => String>] A list of properties for Discord to use in analytics. The following
243
+ # keys are recognised:
244
+ #
245
+ # - "$os" (recommended value: the operating system the bot is running on)
246
+ # - "$browser" (recommended value: library name)
247
+ # - "$device" (recommended value: library name)
248
+ # - "$referrer" (recommended value: empty)
249
+ # - "$referring_domain" (recommended value: empty)
250
+ #
251
+ # @param compress [true, false] Whether certain large packets should be compressed using zlib.
252
+ # @param large_threshold [Integer] The member threshold after which a server counts as large and will have to have
253
+ # its member list chunked.
254
+ def send_identify(token, properties, compress, large_threshold)
255
+ data = {
256
+ # Don't send a v anymore as it's entirely determined by the URL now
257
+ token: token,
258
+ properties: properties,
259
+ compress: compress,
260
+ large_threshold: large_threshold
261
+ }
262
+
263
+ send_packet(Opcodes::IDENTIFY, data)
264
+ end
265
+
266
+ # Sends a status update packet (op 3). This sets the bot user's status (online/idle/...) and game playing/streaming.
267
+ # @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
269
+ # `afk` is true.
270
+ # @param game [Hash<Symbol => Object>, nil] `nil` if no game should be played, or a hash of `:game => "name"` if a
271
+ # game should be played. The hash can also contain additional attributes for streaming statuses.
272
+ # @param afk [true, false] Whether the status was set due to inactivity on the user's part.
273
+ def send_status_update(status, since, game, afk)
274
+ data = {
275
+ status: status,
276
+ since: since,
277
+ game: game,
278
+ afk: afk
279
+ }
280
+
281
+ send_packet(Opcodes::PRESENCE, data)
282
+ end
283
+
284
+ # Sends a voice state update packet (op 4). This packet can connect a user to a voice channel, update self mute/deaf
285
+ # status in an existing voice connection, move the user to a new voice channel on the same server or disconnect an
286
+ # existing voice connection.
287
+ # @param server_id [Integer] The ID of the server on which this action should occur.
288
+ # @param channel_id [Integer, nil] The channel ID to connect/move to, or `nil` to disconnect.
289
+ # @param self_mute [true, false] Whether the user should itself be muted to everyone else.
290
+ # @param self_deaf [true, false] Whether the user should be deaf towards other users.
291
+ def send_voice_state_update(server_id, channel_id, self_mute, self_deaf)
292
+ data = {
293
+ guild_id: server_id,
294
+ channel_id: channel_id,
295
+ self_mute: self_mute,
296
+ self_deaf: self_deaf
297
+ }
298
+
299
+ send_packet(Opcodes::VOICE_STATE, data)
300
+ end
301
+
302
+ # Resumes the session from the last recorded point.
303
+ # @see #send_resume
304
+ def resume
305
+ send_resume(@token, @session.session_id, @session.sequence)
306
+ end
307
+
308
+ # Sends a resume packet (op 6). This replays all events from a previous point specified by its packet sequence. This
309
+ # will not work if the packet to resume from has already been acknowledged using a heartbeat, or if the session ID
310
+ # belongs to a now invalid session.
311
+ #
312
+ # If this packet is sent at the beginning of a connection, it will act similarly to an {#identify} in that it
313
+ # creates a session on the current connection. Unlike identify however, this packet can also be sent in an existing
314
+ # session and will just replay some of the events.
315
+ # @param token [String] The token that was used to identify the session to resume.
316
+ # @param session_id [String] The session ID of the session to resume.
317
+ # @param seq [Integer] The packet sequence of the packet after which the events should be replayed.
318
+ def send_resume(token, session_id, seq)
319
+ data = {
320
+ token: token,
321
+ session_id: session_id,
322
+ seq: seq
323
+ }
324
+
325
+ send_packet(Opcodes::RESUME, data)
326
+ end
327
+
328
+ # Sends a request members packet (op 8). This will order Discord to gradually sent all requested members as dispatch
329
+ # events with type `GUILD_MEMBERS_CHUNK`. It is necessary to use this method in order to get all members of a large
330
+ # server (see `large_threshold` in {#send_identify}), however it can also be used for other purposes.
331
+ # @param server_id [Integer] The ID of the server whose members to query.
332
+ # @param query [String] If this string is not empty, only members whose username starts with this string will be
333
+ # returned.
334
+ # @param limit [Integer] How many members to send at maximum, or `0` to send all members.
335
+ def send_request_members(server_id, query, limit)
336
+ data = {
337
+ guild_id: server_id,
338
+ query: query,
339
+ limit: limit
340
+ }
341
+
342
+ send_packet(Opcodes::REQUEST_MEMBERS, data)
343
+ end
344
+
345
+ private
346
+
347
+ def setup_heartbeats(interval)
348
+ # We don't want to have redundant heartbeat threads, so if one already exists, don't start a new one
349
+ return if @heartbeat_thread
350
+
351
+ @heartbeat_interval = interval
352
+ @heartbeat_thread = Thread.new do
353
+ Thread.current[:discordrb_name] = 'heartbeat'
354
+ 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)
368
+ end
369
+ end
370
+ end
371
+ end
372
+
373
+ def connect_loop
374
+ # Initialize falloff so we wait for more time before reconnecting each time
375
+ @falloff = 1.0
376
+
377
+ @should_reconnect = true
378
+ loop do
379
+ connect
380
+
381
+ break unless @should_reconnect
382
+
383
+ if @instant_reconnect
384
+ # We got an op 7! Don't wait before reconnecting
385
+ LOGGER.info('Got an op 7, reconnecting right away')
386
+ @instant_reconnect = false
387
+ else
388
+ wait_for_reconnect
389
+ end
390
+
391
+ # Restart the loop, i. e. reconnect
392
+ end
393
+ end
394
+
395
+ # Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
396
+ # unexpected way
397
+ def wait_for_reconnect
398
+ # We disconnected in an unexpected way! Wait before reconnecting so we don't spam Discord's servers.
399
+ LOGGER.debug("Attempting to reconnect in #{@falloff} seconds.")
400
+ sleep @falloff
401
+
402
+ # Calculate new falloff
403
+ @falloff *= 1.5
404
+ @falloff = 115 + (rand * 10) if @falloff > 120 # Cap the falloff at 120 seconds and then add some random jitter
405
+ end
406
+
407
+ # Create and connect a socket using a URI
408
+ def obtain_socket(uri)
409
+ socket = TCPSocket.new(uri.host, uri.port || socket_port(uri))
410
+
411
+ if secure_uri?(uri)
412
+ ctx = OpenSSL::SSL::SSLContext.new
413
+ ctx.ssl_version = 'SSLv23'
414
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
415
+
416
+ cert_store = OpenSSL::X509::Store.new
417
+ cert_store.set_default_paths
418
+ ctx.cert_store = cert_store
419
+
420
+ socket = ::OpenSSL::SSL::SSLSocket.new(socket, ctx)
421
+ socket.connect
422
+ end
423
+
424
+ socket
425
+ end
426
+
427
+ # Whether the URI is secure (connection should be encrypted)
428
+ def secure_uri?(uri)
429
+ %w(https wss).include? uri.scheme
430
+ end
431
+
432
+ # The port we should connect to, if the URI doesn't have one set.
433
+ def socket_port(uri)
434
+ secure_uri?(uri) ? 443 : 80
435
+ end
436
+
437
+ def find_gateway
438
+ response = API.gateway(@token)
439
+ JSON.parse(response)['url']
440
+ end
441
+
442
+ def process_gateway
443
+ raw_url = find_gateway
444
+
445
+ # Append a slash in case it's not there (I'm not sure how well WSCS handles it otherwise)
446
+ raw_url += '/' unless raw_url.end_with? '/'
447
+
448
+ # Add the parameters we want
449
+ raw_url + "?encoding=json&v=#{GATEWAY_VERSION}"
450
+ end
451
+
452
+ def connect
453
+ LOGGER.debug('Connecting')
454
+
455
+ # Get the URI we should connect to
456
+ url = process_gateway
457
+ LOGGER.debug("Gateway URL: #{url}")
458
+
459
+ # Parse it
460
+ gateway_uri = URI.parse(url)
461
+
462
+ # Connect to the obtained URI with a socket
463
+ @socket = obtain_socket(gateway_uri)
464
+ LOGGER.debug('Obtained socket')
465
+
466
+ # Initialise some properties
467
+ @handshake = ::WebSocket::Handshake::Client.new(url: url) # Represents the handshake between us and the server
468
+ @handshaked = false # Whether the handshake has finished yet
469
+ @pipe_broken = false # Whether we've received an EPIPE at any time
470
+ @closed = false # Whether the websocket is currently closed
471
+
472
+ # We're done! Delegate to the websocket loop
473
+ websocket_loop
474
+ rescue => e
475
+ LOGGER.error('An error occurred while connecting to the websocket!')
476
+ LOGGER.log_exception(e)
477
+ end
478
+
479
+ def websocket_loop
480
+ # Send the handshake data that we have so far
481
+ @socket.write(@handshake.to_s)
482
+
483
+ # Create a frame to handle received data
484
+ frame = ::WebSocket::Frame::Incoming::Client.new
485
+
486
+ until @closed
487
+ begin
488
+ recv_data = nil
489
+
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 }
492
+
493
+ # Check if we actually got data
494
+ unless recv_data
495
+ # If we didn't, wait
496
+ sleep 1
497
+ next
498
+ end
499
+
500
+ # Check whether the handshake has finished yet
501
+ if @handshaked
502
+ # If it hasn't, add the received data to the current frame
503
+ frame << recv_data
504
+
505
+ # Try to parse a message from the frame
506
+ msg = frame.next
507
+ while msg
508
+ # Check whether the message is a close frame, and if it is, handle accordingly
509
+ if msg.respond_to?(:code) && msg.code
510
+ handle_internal_close(msg)
511
+ break
512
+ end
513
+
514
+ # If there is one, handle it and try again
515
+ handle_message(msg.data)
516
+ msg = frame.next
517
+ end
518
+ else
519
+ # If the handshake hasn't finished, handle it
520
+ handle_handshake_data(recv_data)
521
+ end
522
+ rescue => e
523
+ handle_error(e)
524
+ end
525
+ end
526
+ end
527
+
528
+ def handle_handshake_data(recv_data)
529
+ @handshake << recv_data
530
+ return unless @handshake.finished?
531
+
532
+ @handshaked = true
533
+ handle_open
534
+ end
535
+
536
+ def handle_open
537
+ end
538
+
539
+ def handle_error(e)
540
+ LOGGER.error('An error occurred in the main websocket loop!')
541
+ LOGGER.log_exception(e)
542
+ end
543
+
544
+ def handle_message(msg)
545
+ if msg.byteslice(0) == 'x'
546
+ # The message is compressed, inflate it
547
+ msg = Zlib::Inflate.inflate(msg)
548
+ end
549
+
550
+ # Parse packet
551
+ packet = JSON.parse(msg)
552
+ op = packet['op'].to_i
553
+
554
+ LOGGER.in(packet)
555
+
556
+ # If the packet has a sequence defined (all dispatch packets have one), make sure to update that in the
557
+ # session so it will be acknowledged next heartbeat.
558
+ # Only do this, of course, if a session has been created already; for a READY dispatch (which has s=0 set but is
559
+ # the packet that starts the session in the first place) we need not do any handling since initialising the
560
+ # session will set it to 0 by default.
561
+ @session.sequence = packet['s'] if packet['s'] && @session
562
+
563
+ case op
564
+ when Opcodes::DISPATCH
565
+ handle_dispatch(packet)
566
+ when Opcodes::HELLO
567
+ handle_hello(packet)
568
+ when Opcodes::RECONNECT
569
+ handle_reconnect
570
+ when Opcodes::INVALIDATE_SESSION
571
+ handle_invalidate_session
572
+ when Opcodes::HEARTBEAT_ACK
573
+ handle_heartbeat_ack(packet)
574
+ else
575
+ LOGGER.warn("Received invalid opcode #{op} - please report with this information: #{msg}")
576
+ end
577
+ end
578
+
579
+ # Op 0
580
+ def handle_dispatch(packet)
581
+ data = packet['d']
582
+ type = packet['t'].intern
583
+
584
+ case type
585
+ when :READY
586
+ LOGGER.info("Discord using gateway protocol version: #{data['v']}, requested: #{GATEWAY_VERSION}")
587
+
588
+ @session = Session.new(data['session_id'])
589
+ @session.sequence = 0
590
+ when :RESUMED
591
+ # The RESUMED event is received after a successful op 6 (resume). It does nothing except tell the bot the
592
+ # connection is initiated (like READY would). Starting with v5, it doesn't set a new heartbeat interval anymore
593
+ # since that is handled by op 10 (HELLO).
594
+ LOGGER.debug('Connection resumed')
595
+ return
596
+ end
597
+
598
+ @bot.dispatch(type, data)
599
+ end
600
+
601
+ # Op 7
602
+ def handle_reconnect
603
+ @instant_reconnect = true
604
+ close
605
+
606
+ # Suspend session so we resume afterwards
607
+ @session.suspend
608
+ end
609
+
610
+ # Op 9
611
+ def handle_invalidate_session
612
+ LOGGER.debug('Received op 9, invalidating session and reidentifying.')
613
+
614
+ if @session
615
+ @session.invalidate
616
+ else
617
+ LOGGER.warn('Received op 9 without a running session! Not invalidating, we *should* be fine though.')
618
+ end
619
+
620
+ identify
621
+ end
622
+
623
+ # Op 10
624
+ def handle_hello(packet)
625
+ LOGGER.debug('Hello!')
626
+
627
+ # The heartbeat interval is given in ms, so divide it by 1000 to get seconds
628
+ interval = packet['d']['heartbeat_interval'].to_f / 1000.0
629
+ setup_heartbeats(interval)
630
+
631
+ LOGGER.debug("Trace: #{packet['d']['_trace']}")
632
+
633
+ if @session && @session.should_resume?
634
+ resume
635
+ else
636
+ identify
637
+ end
638
+ end
639
+
640
+ # Op 11
641
+ def handle_heartbeat_ack(packet)
642
+ LOGGER.debug("Received heartbeat ack for packet: #{packet.inspect}")
643
+ end
644
+
645
+ # Called when the websocket has been disconnected in some way - say due to a pipe error while sending
646
+ def handle_internal_close(e)
647
+ close
648
+ handle_close(e)
649
+ end
650
+
651
+ def handle_close(e)
652
+ if e.respond_to? :code
653
+ # It is a proper close frame we're dealing with, print reason and message to console
654
+ LOGGER.error('Websocket close frame received!')
655
+ LOGGER.error("Code: #{e.code}")
656
+ LOGGER.error("Message: #{e.data}")
657
+ elsif e.is_a? Exception
658
+ # Log the exception
659
+ LOGGER.error('The websocket connection has closed due to an error!')
660
+ LOGGER.log_exception(e)
661
+ else
662
+ LOGGER.error("The websocket connection has closed: #{e.inspect}")
663
+ end
664
+ end
665
+
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)
676
+ LOGGER.out(data)
677
+
678
+ unless @handshaked && !@closed
679
+ # If we're not handshaked or closed, it means there's no connection to send anything to
680
+ raise 'Tried to send something to the websocket while not being connected!'
681
+ end
682
+
683
+ # Create the frame we're going to send
684
+ frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version)
685
+
686
+ # Try to send it
687
+ begin
688
+ @socket.write frame.to_s
689
+ rescue Errno::EPIPE => e
690
+ # There has been an error!
691
+ @pipe_broken = true
692
+ handle_internal_close(e)
693
+ end
694
+ end
695
+
696
+ def close(no_sync = false)
697
+ # If we're already closed, there's no need to do anything - return
698
+ return if @closed
699
+
700
+ # Suspend the session so we don't send heartbeats
701
+ @session.suspend if @session
702
+
703
+ # Send a close frame (if we can)
704
+ send nil, :close unless @pipe_broken
705
+
706
+ # 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
715
+
716
+ # Close the socket if possible
717
+ @socket.close if @socket
718
+ @socket = nil
719
+
720
+ # Make sure we do necessary things as soon as we're closed
721
+ handle_close(nil)
722
+ end
723
+ end
724
+ end