rubycord 1.0.0

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