rubycord 1.0.0

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