discordrb 1.8.1 → 2.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.

Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.overcommit.yml +7 -0
  3. data/.rubocop.yml +5 -4
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +25 -15
  6. data/discordrb.gemspec +2 -3
  7. data/examples/commands.rb +14 -2
  8. data/examples/ping.rb +1 -1
  9. data/examples/pm_send.rb +1 -1
  10. data/lib/discordrb.rb +9 -0
  11. data/lib/discordrb/api.rb +176 -50
  12. data/lib/discordrb/await.rb +3 -0
  13. data/lib/discordrb/bot.rb +607 -372
  14. data/lib/discordrb/cache.rb +208 -0
  15. data/lib/discordrb/commands/command_bot.rb +50 -18
  16. data/lib/discordrb/commands/container.rb +11 -2
  17. data/lib/discordrb/commands/events.rb +2 -0
  18. data/lib/discordrb/commands/parser.rb +10 -8
  19. data/lib/discordrb/commands/rate_limiter.rb +2 -0
  20. data/lib/discordrb/container.rb +24 -25
  21. data/lib/discordrb/data.rb +521 -219
  22. data/lib/discordrb/errors.rb +6 -7
  23. data/lib/discordrb/events/await.rb +2 -0
  24. data/lib/discordrb/events/bans.rb +3 -1
  25. data/lib/discordrb/events/channels.rb +124 -0
  26. data/lib/discordrb/events/generic.rb +2 -0
  27. data/lib/discordrb/events/guilds.rb +16 -13
  28. data/lib/discordrb/events/lifetime.rb +12 -2
  29. data/lib/discordrb/events/members.rb +26 -15
  30. data/lib/discordrb/events/message.rb +20 -7
  31. data/lib/discordrb/events/presence.rb +18 -2
  32. data/lib/discordrb/events/roles.rb +83 -0
  33. data/lib/discordrb/events/typing.rb +15 -2
  34. data/lib/discordrb/events/voice_state_update.rb +2 -0
  35. data/lib/discordrb/light.rb +8 -0
  36. data/lib/discordrb/light/data.rb +62 -0
  37. data/lib/discordrb/light/integrations.rb +73 -0
  38. data/lib/discordrb/light/light_bot.rb +56 -0
  39. data/lib/discordrb/logger.rb +4 -0
  40. data/lib/discordrb/permissions.rb +16 -12
  41. data/lib/discordrb/token_cache.rb +3 -0
  42. data/lib/discordrb/version.rb +3 -1
  43. data/lib/discordrb/voice/encoder.rb +2 -0
  44. data/lib/discordrb/voice/network.rb +21 -14
  45. data/lib/discordrb/voice/voice_bot.rb +26 -3
  46. data/lib/discordrb/websocket.rb +69 -0
  47. metadata +15 -26
  48. data/lib/discordrb/events/channel_create.rb +0 -44
  49. data/lib/discordrb/events/channel_delete.rb +0 -44
  50. data/lib/discordrb/events/channel_update.rb +0 -46
  51. data/lib/discordrb/events/guild_role_create.rb +0 -35
  52. data/lib/discordrb/events/guild_role_delete.rb +0 -36
  53. data/lib/discordrb/events/guild_role_update.rb +0 -35
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'discordrb/container'
2
4
 
3
5
  module Discordrb
@@ -26,6 +28,7 @@ module Discordrb
26
28
  attr_reader :attributes
27
29
 
28
30
  # Makes a new await. For internal use only.
31
+ # @!visibility private
29
32
  def initialize(bot, key, type, attributes, block = nil)
30
33
  @bot = bot
31
34
  @key = key
@@ -1,19 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rest-client'
2
- require 'faye/websocket'
3
- require 'eventmachine'
4
+ require 'zlib'
4
5
 
5
6
  require 'discordrb/events/message'
6
7
  require 'discordrb/events/typing'
7
8
  require 'discordrb/events/lifetime'
8
9
  require 'discordrb/events/presence'
9
10
  require 'discordrb/events/voice_state_update'
10
- require 'discordrb/events/channel_create'
11
- require 'discordrb/events/channel_update'
12
- require 'discordrb/events/channel_delete'
11
+ require 'discordrb/events/channels'
13
12
  require 'discordrb/events/members'
14
- require 'discordrb/events/guild_role_create'
15
- require 'discordrb/events/guild_role_delete'
16
- require 'discordrb/events/guild_role_update'
13
+ require 'discordrb/events/roles'
17
14
  require 'discordrb/events/guilds'
18
15
  require 'discordrb/events/await'
19
16
  require 'discordrb/events/bans'
@@ -24,17 +21,65 @@ require 'discordrb/data'
24
21
  require 'discordrb/await'
25
22
  require 'discordrb/token_cache'
26
23
  require 'discordrb/container'
24
+ require 'discordrb/websocket'
25
+ require 'discordrb/cache'
27
26
 
28
27
  require 'discordrb/voice/voice_bot'
29
28
 
30
29
  module Discordrb
30
+ # Gateway packet opcodes
31
+ module Opcodes
32
+ # **Received** when Discord dispatches an event to the gateway (like MESSAGE_CREATE, PRESENCE_UPDATE or whatever).
33
+ # The vast majority of received packets will have this opcode.
34
+ DISPATCH = 0
35
+
36
+ # **Two-way**: The client has to send a packet with this opcode every ~40 seconds (actual interval specified in
37
+ # READY or RESUMED) and the current sequence number, otherwise it will be disconnected from the gateway. In certain
38
+ # cases Discord may also send one, specifically if two clients are connected at once.
39
+ HEARTBEAT = 1
40
+
41
+ # **Sent**: This is one of the two possible ways to initiate a session after connecting to the gateway. It
42
+ # should contain the authentication token along with other stuff the server has to know right from the start, such
43
+ # as large_threshold and, for older gateway versions, the desired version.
44
+ IDENTIFY = 2
45
+
46
+ # **Sent**: Packets with this opcode are used to change the user's status and played game. (Sending this is never
47
+ # necessary for a gateway client to behave correctly)
48
+ PRESENCE = 3
49
+
50
+ # **Sent**: Packets with this opcode are used to change a user's voice state (mute/deaf/unmute/undeaf/etc.). It is
51
+ # also used to connect to a voice server in the first place. (Sending this is never necessary for a gateway client
52
+ # to behave correctly)
53
+ VOICE_STATE = 4
54
+
55
+ # **Sent**: This opcode is used to ping a voice server, whatever that means. The functionality of this opcode isn't
56
+ # known well but non-user clients should never send it.
57
+ VOICE_PING = 5
58
+
59
+ # **Sent**: This is the other of two possible ways to initiate a gateway session (other than {IDENTIFY}). Rather
60
+ # than starting an entirely new session, it resumes an existing session by replaying all events from a given
61
+ # sequence number. It should be used to recover from a connection error or anything like that when the session is
62
+ # still valid - sending this with an invalid session will cause an error to occur.
63
+ RESUME = 6
64
+
65
+ # **Received**: Discord sends this opcode to indicate that the client should reconnect to a different gateway
66
+ # server because the old one is currently being decommissioned. Counterintuitively, this opcode also invalidates the
67
+ # session - the client has to create an entirely new session with the new gateway instead of resuming the old one.
68
+ RECONNECT = 7
69
+
70
+ # **Sent**: This opcode identifies packets used to retrieve a list of members from a particular server. There is
71
+ # also a REST endpoint available for this, but it is inconvenient to use because the client has to implement
72
+ # pagination itself, whereas sending this opcode lets Discord handle the pagination and the client can just add
73
+ # members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
74
+ REQUEST_MEMBERS = 8
75
+
76
+ # **Received**: The functionality of this opcode is less known than the others but it appears to specifically
77
+ # tell the client to invalidate its local session and continue by {IDENTIFY}ing.
78
+ INVALIDATE_SESSION = 9
79
+ end
80
+
31
81
  # Represents a Discord bot, including servers, users, etc.
32
82
  class Bot
33
- # The user that represents the bot itself. This version will always be identical to
34
- # the user determined by {#user} called with the bot's ID.
35
- # @return [User] The bot user.
36
- attr_reader :bot_user
37
-
38
83
  # The list of users the bot shares a server with.
39
84
  # @return [Array<User>] The users.
40
85
  attr_reader :users
@@ -54,6 +99,7 @@ module Discordrb
54
99
  # to edit user data like the current username (see {Profile#username=}).
55
100
  # @return [Profile] The bot's profile that can be used to edit data.
56
101
  attr_reader :profile
102
+ alias_method :bot_user, :profile
57
103
 
58
104
  # Whether or not the bot should parse its own messages. Off by default.
59
105
  attr_accessor :should_parse_self
@@ -63,36 +109,80 @@ module Discordrb
63
109
  attr_accessor :name
64
110
 
65
111
  include EventContainer
112
+ include Cache
66
113
 
67
- # Makes a new bot with the given email and password. It will be ready to be added event handlers to and can eventually be run with {#run}.
114
+ # Makes a new bot with the given authentication data. It will be ready to be added event handlers to and can
115
+ # eventually be run with {#run}.
116
+ #
117
+ # Depending on the authentication information present, discordrb will deduce whether you're running on a user or a
118
+ # bot account. (Discord recommends using bot accounts whenever possible.) The following sets of authentication
119
+ # information are valid:
120
+ # * token + application_id (bot account)
121
+ # * email + password (user account)
122
+ # * email + password + token (user account; the given token will be used for authentication instead of email
123
+ # and password)
124
+ #
125
+ # Simply creating a bot won't be enough to start sending messages etc. with, only a limited set of methods can
126
+ # be used after logging in. If you want to do something when the bot has connected successfully, either do it in the
127
+ # {#ready} event, or use the {#run} method with the :async parameter and do the processing after that.
68
128
  # @param email [String] The email for your (or the bot's) Discord account.
69
129
  # @param password [String] The valid password that should be used to log in to the account.
70
- # @param debug [Boolean] Whether or not the bug should run in debug mode, which gives increased console output.
71
- def initialize(email, password, debug = false)
130
+ # @param log_mode [Symbol] The mode this bot should use for logging. See {Logger#mode=} for a list of modes.
131
+ # @param token [String] The token that should be used to log in. If your bot is a bot account, you have to specify
132
+ # this. If you're logging in as a user, make sure to also set the account type to :user so discordrb doesn't think
133
+ # you're trying to log in as a bot.
134
+ # @param application_id [Integer] If you're logging in as a bot, the bot's application ID.
135
+ # @param type [Symbol] This parameter lets you manually overwrite the account type. If this isn't specified, it will
136
+ # be determined by checking what other attributes are there. The only use case for this is if you want to log in
137
+ # as a user but only with a token. Valid values are :user and :bot.
138
+ # @param name [String] Your bot's name. This will be sent to Discord with any API requests, who will use this to
139
+ # trace the source of excessive API requests; it's recommended to set this to something if you make bots that many
140
+ # people will host on their servers separately.
141
+ # @param fancy_log [true, false] Whether the output log should be made extra fancy using ANSI escape codes. (Your
142
+ # terminal may not support this.)
143
+ # @param suppress_ready [true, false] Whether the READY packet should be exempt from being printed to console.
144
+ # Useful for very large bots running in debug or verbose log_mode.
145
+ # @param parse_self [true, false] Whether the bot should react on its own messages. It's best to turn this off
146
+ # unless you really need this so you don't inadvertently create infinite loops.
147
+ def initialize(
148
+ email: nil, password: nil, log_mode: :normal,
149
+ token: nil, application_id: nil,
150
+ type: nil, name: '', fancy_log: false, suppress_ready: false, parse_self: false)
72
151
  # Make sure people replace the login details in the example files...
73
152
  if email.is_a?(String) && email.end_with?('example.com')
74
153
  puts 'You have to replace the login details in the example files with your own!'
75
154
  exit
76
155
  end
77
156
 
78
- LOGGER.debug = debug
79
- @should_parse_self = false
157
+ LOGGER.mode = if log_mode.is_a? TrueClass # Specifically check for `true` because people might not have updated yet
158
+ :debug
159
+ else
160
+ log_mode
161
+ end
162
+
163
+ @should_parse_self = parse_self
80
164
 
81
165
  @email = email
82
166
  @password = password
83
167
 
84
- @name = ''
168
+ @application_id = application_id
169
+
170
+ @type = determine_account_type(type, email, password, token, application_id)
171
+
172
+ @name = name
173
+
174
+ LOGGER.fancy = fancy_log
175
+ @prevent_ready = suppress_ready
85
176
 
86
177
  debug('Creating token cache')
87
- @token_cache = Discordrb::TokenCache.new
178
+ token_cache = Discordrb::TokenCache.new
88
179
  debug('Token cache created successfully')
89
- @token = login
180
+ @token = login(type, email, password, token, token_cache)
90
181
 
91
- @channels = {}
92
- @users = {}
182
+ init_cache
93
183
 
94
- # Channels the bot has no permission to, for internal tracking
95
- @restricted_channels = []
184
+ @voices = {}
185
+ @should_connect_to_voice = {}
96
186
 
97
187
  @event_threads = []
98
188
  @current_thread = 0
@@ -106,6 +196,12 @@ module Discordrb
106
196
  @token
107
197
  end
108
198
 
199
+ # @return the raw token, without any prefix
200
+ # @see #token
201
+ def raw_token
202
+ @token.split(' ').last
203
+ end
204
+
109
205
  # Runs the bot, which logs into Discord and connects the WebSocket. This prevents all further execution unless it is executed with `async` = `:async`.
110
206
  # @param async [Symbol] If it is `:async`, then the bot will allow further execution.
111
207
  # It doesn't necessarily have to be that, anything truthy will work,
@@ -142,14 +238,21 @@ module Discordrb
142
238
 
143
239
  loop do
144
240
  websocket_connect
145
- debug("Disconnected! Attempting to reconnect in #{@falloff} seconds.")
146
- sleep @falloff
147
- @token = login
148
241
 
149
- # Calculate new falloff
150
- @falloff *= 1.5
151
- @falloff = 115 + (rand * 10) if @falloff > 1 # Cap the falloff at 120 seconds and then add some random jitter
242
+ # websocket_connect is blocking so being in here means we're disconnected
243
+ LOGGER.warn('Oh dear, we got disconnected!')
244
+
245
+ if @reconnect_url
246
+ # We got an op 7! Don't wait before reconnecting
247
+ debug('Got an op 7, reconnecting right away')
248
+ else
249
+ wait_for_reconnect
250
+ end
251
+
252
+ # Restart the loop, i. e. reconnect
152
253
  end
254
+
255
+ LOGGER.warn('The WS loop exited! Not sure if this is a good thing')
153
256
  end
154
257
 
155
258
  debug('WS thread created! Now waiting for confirmation that everything worked')
@@ -168,123 +271,95 @@ module Discordrb
168
271
  @ws_thread.kill
169
272
  end
170
273
 
171
- # Gets a channel given its ID. This queries the internal channel cache, and if the channel doesn't
172
- # exist in there, it will get the data from Discord.
173
- # @param id [Integer] The channel ID for which to search for.
174
- # @return [Channel] The channel identified by the ID.
175
- def channel(id)
176
- id = id.resolve_id
177
-
178
- raise Discordrb::Errors::NoPermission if @restricted_channels.include? id
274
+ # Makes the bot join an invite to a server.
275
+ # @param invite [String, Invite] The invite to join. For possible formats see {#resolve_invite_code}.
276
+ def join(invite)
277
+ resolved = invite(invite).code
278
+ API.join_server(token, resolved)
279
+ end
179
280
 
180
- debug("Obtaining data for channel with id #{id}")
181
- return @channels[id] if @channels[id]
281
+ # Creates an OAuth invite URL that can be used to invite this bot to a particular server.
282
+ # Requires the application ID to have been set during initialization.
283
+ # @param server [Server, nil] The server the bot should be invited to, or nil if a general invite should be created.
284
+ # @return [String] the OAuth invite URL.
285
+ def invite_url(server = nil)
286
+ raise 'No application ID has been set during initialization! Add one as the `application_id` named parameter while creating your bot.' unless @application_id
182
287
 
183
- begin
184
- response = API.channel(token, id)
185
- channel = Channel.new(JSON.parse(response), self)
186
- @channels[id] = channel
187
- rescue Discordrb::Errors::NoPermission
188
- debug "Tried to get access to restricted channel #{id}, blacklisting it"
189
- @restricted_channels << id
190
- raise
191
- end
288
+ guild_id_str = server ? "&guild_id=#{server.id}" : ''
289
+ "https://discordapp.com/oauth2/authorize?&client_id=#{@application_id}#{guild_id_str}&scope=bot"
192
290
  end
193
291
 
194
- # Creates a private channel for the given user ID, or if one exists already, returns that one.
195
- # It is recommended that you use {User#pm} instead, as this is mainly for internal use. However,
196
- # usage of this method may be unavoidable if only the user ID is known.
197
- # @param id [Integer] The user ID to generate a private channel for.
198
- # @return [Channel] A private channel for that user.
199
- def private_channel(id)
200
- id = id.resolve_id
201
- debug("Creating private channel with user id #{id}")
202
- return @private_channels[id] if @private_channels[id]
292
+ # @return [Hash<Integer => VoiceBot>] the voice connections this bot currently has, by the server ID to which they are connected.
293
+ attr_reader :voices
203
294
 
204
- response = API.create_private(token, @bot_user.id, id)
205
- channel = Channel.new(JSON.parse(response), self)
206
- @private_channels[id] = channel
207
- end
295
+ # Gets the voice bot for a particular server or channel. You can connect to a new channel using the {#voice_connect}
296
+ # method.
297
+ # @param thing [Channel, Server, Integer] the server or channel you want to get the voice bot for, or its ID.
298
+ # @return [VoiceBot, nil] the VoiceBot for the thing you specified, or nil if there is no connection yet
299
+ def voice(thing)
300
+ id = thing.resolve_id
301
+ return @voices[id] if @voices[id]
208
302
 
209
- # Gets the code for an invite.
210
- # @param invite [String, Invite] The invite to get the code for. Possible formats are:
211
- #
212
- # * An {Invite} object
213
- # * The code for an invite
214
- # * A fully qualified invite URL (e. g. `https://discordapp.com/invite/0A37aN7fasF7n83q`)
215
- # * A short invite URL with protocol (e. g. `https://discord.gg/0A37aN7fasF7n83q`)
216
- # * A short invite URL without protocol (e. g. `discord.gg/0A37aN7fasF7n83q`)
217
- # @return [String] Only the code for the invite.
218
- def resolve_invite_code(invite)
219
- invite = invite.code if invite.is_a? Discordrb::Invite
220
- invite = invite[invite.rindex('/') + 1..-1] if invite.start_with?('http', 'discord.gg')
221
- invite
222
- end
223
-
224
- # Gets information about an invite.
225
- # @param invite [String, Invite] The invite to join. For possible formats see {#resolve_invite_code}.
226
- # @return [Invite] The invite with information about the given invite URL.
227
- def invite(invite)
228
- code = resolve_invite_code(invite)
229
- Invite.new(JSON.parse(API.resolve_invite(token, code)), self)
230
- end
303
+ channel = channel(id)
304
+ return nil unless channel
231
305
 
232
- # Makes the bot join an invite to a server.
233
- # @param invite [String, Invite] The invite to join. For possible formats see {#resolve_invite_code}.
234
- def join(invite)
235
- resolved = invite(invite).code
236
- API.join_server(token, resolved)
237
- end
306
+ server_id = channel.server.id
307
+ return @voices[server_id] if @voices[server_id]
238
308
 
239
- attr_reader :voice
309
+ nil
310
+ end
240
311
 
241
312
  # Connects to a voice channel, initializes network connections and returns the {Voice::VoiceBot} over which audio
242
- # data can then be sent. After connecting, the bot can also be accessed using {#voice}.
313
+ # data can then be sent. After connecting, the bot can also be accessed using {#voice}. If the bot is already
314
+ # connected to voice, the existing connection will be terminated - you don't have to call {VoiceBot#destroy}
315
+ # before calling this method.
243
316
  # @param chan [Channel] The voice channel to connect to.
244
317
  # @param encrypted [true, false] Whether voice communication should be encrypted using RbNaCl's SecretBox
245
318
  # (uses an XSalsa20 stream cipher for encryption and Poly1305 for authentication)
246
319
  # @return [Voice::VoiceBot] the initialized bot over which audio data can then be sent.
247
320
  def voice_connect(chan, encrypted = true)
248
- if @voice
249
- debug('Voice bot exists already! Destroying it')
250
- @voice.destroy
251
- @voice = nil
252
- end
253
-
254
321
  chan = channel(chan.resolve_id)
255
- @voice_channel = chan
322
+ server_id = chan.server.id
256
323
  @should_encrypt_voice = encrypted
257
324
 
258
- debug("Got voice channel: #{@voice_channel}")
325
+ if @voices[chan.id]
326
+ debug('Voice bot exists already! Destroying it')
327
+ @voices[chan.id].destroy
328
+ @voices.delete(chan.id)
329
+ end
330
+
331
+ debug("Got voice channel: #{chan}")
259
332
 
260
333
  data = {
261
- op: 4,
334
+ op: Opcodes::VOICE_STATE,
262
335
  d: {
263
- guild_id: @voice_channel.server.id.to_s,
264
- channel_id: @voice_channel.id.to_s,
336
+ guild_id: server_id.to_s,
337
+ channel_id: chan.id.to_s,
265
338
  self_mute: false,
266
339
  self_deaf: false
267
340
  }
268
341
  }
269
342
  debug("Voice channel init packet is: #{data.to_json}")
270
343
 
271
- @should_connect_to_voice = true
344
+ @should_connect_to_voice[server_id] = chan
272
345
  @ws.send(data.to_json)
273
346
  debug('Voice channel init packet sent! Now waiting.')
274
347
 
275
- sleep(0.05) until @voice
348
+ sleep(0.05) until @voices[server_id]
276
349
  debug('Voice connect succeeded!')
277
- @voice
350
+ @voices[server_id]
278
351
  end
279
352
 
280
- # Disconnects the client from all voice connections across Discord.
353
+ # Disconnects the client from a specific voice connection given the server ID. Usually it's more convenient to use
354
+ # {Discordrb::Voice::VoiceBot#destroy} rather than this.
355
+ # @param server_id [Integer] The ID of the server the voice connection is on.
281
356
  # @param destroy_vws [true, false] Whether or not the VWS should also be destroyed. If you're calling this method
282
357
  # directly, you should leave it as true.
283
- def voice_destroy(destroy_vws = true)
358
+ def voice_destroy(server_id, destroy_vws = true)
284
359
  data = {
285
- op: 4,
360
+ op: Opcodes::VOICE_STATE,
286
361
  d: {
287
- guild_id: nil,
362
+ guild_id: server_id.to_s,
288
363
  channel_id: nil,
289
364
  self_mute: false,
290
365
  self_deaf: false
@@ -294,8 +369,8 @@ module Discordrb
294
369
  debug("Voice channel destroy packet is: #{data.to_json}")
295
370
  @ws.send(data.to_json)
296
371
 
297
- @voice.destroy if @voice && destroy_vws
298
- @voice = nil
372
+ @voices[server_id].destroy if @voices[server_id] && destroy_vws
373
+ @voices.delete(server_id)
299
374
  end
300
375
 
301
376
  # Revokes an invite to a server. Will fail unless you have the *Manage Server* permission.
@@ -306,53 +381,6 @@ module Discordrb
306
381
  API.delete_invite(token, invite)
307
382
  end
308
383
 
309
- # Gets a user by its ID.
310
- # @note This can only resolve users known by the bot (i.e. that share a server with the bot).
311
- # @param id [Integer] The user ID that should be resolved.
312
- # @return [User, nil] The user identified by the ID, or `nil` if it couldn't be found.
313
- def user(id)
314
- id = id.resolve_id
315
- @users[id]
316
- end
317
-
318
- # Gets a server by its ID.
319
- # @note This can only resolve servers the bot is currently in.
320
- # @param id [Integer] The server ID that should be resolved.
321
- # @return [Server, nil] The server identified by the ID, or `nil` if it couldn't be found.
322
- def server(id)
323
- id = id.resolve_id
324
- @servers[id]
325
- end
326
-
327
- # Finds a channel given its name and optionally the name of the server it is in.
328
- # @param channel_name [String] The channel to search for.
329
- # @param server_name [String] The server to search for, or `nil` if only the channel should be searched for.
330
- # @return [Array<Channel>] The array of channels that were found. May be empty if none were found.
331
- def find_channel(channel_name, server_name = nil)
332
- results = []
333
-
334
- @servers.values.each do |server|
335
- server.channels.each do |channel|
336
- results << channel if channel.name == channel_name && (server_name || server.name) == server.name
337
- end
338
- end
339
-
340
- results
341
- end
342
-
343
- # Finds a user given its username.
344
- # @param username [String] The username to look for.
345
- # @return [Array<User>] The array of users that were found. May be empty if none were found.
346
- def find_user(username)
347
- @users.values.find_all { |e| e.username == username }
348
- end
349
-
350
- # @deprecated Use {#find_channel} instead
351
- def find(channel_name, server_name = nil)
352
- debug('Attempted to use bot.find - this method is deprecated! Use find_channel for the same functionality')
353
- find_channel(channel_name, server_name)
354
- end
355
-
356
384
  # Sends a text message to a channel given its ID and the message's content.
357
385
  # @param channel_id [Integer] The ID that identifies the channel to send something to.
358
386
  # @param content [String] The text that should be sent as a message. It is limited to 2000 characters (Discord imposed).
@@ -435,7 +463,7 @@ module Discordrb
435
463
  @game = name
436
464
 
437
465
  data = {
438
- op: 3,
466
+ op: Opcodes::PRESENCE,
439
467
  d: {
440
468
  idle_since: nil,
441
469
  game: name ? { name: name } : nil
@@ -446,6 +474,27 @@ module Discordrb
446
474
  name
447
475
  end
448
476
 
477
+ # Injects a reconnect event (op 7) into the event processor, causing Discord to reconnect to the given gateway URL.
478
+ # If the URL is set to nil, it will reconnect and get an entirely new gateway URL. This method has not much use
479
+ # outside of testing and implementing highly custom reconnect logic.
480
+ # @param url [String, nil] the URL to connect to or nil if one should be obtained from Discord.
481
+ def inject_reconnect(url)
482
+ websocket_message({
483
+ op: Opcodes::RECONNECT,
484
+ d: {
485
+ url: url
486
+ }
487
+ }.to_json)
488
+ end
489
+
490
+ # Injects a resume packet (op 6) into the gateway. If this is done with a running connection, it will cause an
491
+ # error. It has no use outside of testing stuff that I know of, but if you want to use it anyway for some reason,
492
+ # here it is.
493
+ # @param seq [Integer, nil] The sequence ID to inject, or nil if the currently tracked one should be used.
494
+ def inject_resume(seq)
495
+ resume(seq || @sequence, raw_token, @session_id)
496
+ end
497
+
449
498
  # Sets debug mode. If debug mode is on, many things will be outputted to STDOUT.
450
499
  def debug=(new_debug)
451
500
  LOGGER.debug = new_debug
@@ -488,29 +537,24 @@ module Discordrb
488
537
 
489
538
  private
490
539
 
491
- ####### ### ###### ## ## ########
492
- ## ## ## ## ## ## ## ## ##
493
- ## ## ## ## ## ## ##
494
- ## ## ## ## ######### ######
495
- ## ######### ## ## ## ##
496
- ## ## ## ## ## ## ## ## ##
497
- ####### ## ## ###### ## ## ########
498
-
499
- def add_server(data)
500
- server = Server.new(data, self)
501
- @servers[server.id] = server
502
-
503
- # Initialize users
504
- server.members.each do |member|
505
- if @users[member.id]
506
- # If the user is already cached, just add the new roles
507
- @users[member.id].merge_roles(server, member.roles[server.id])
508
- else
509
- @users[member.id] = member
510
- end
511
- end
540
+ # Determines the type of an account by checking which parameters are given
541
+ def determine_account_type(type, email, password, token, application_id)
542
+ # Case 1: if a type is already given, return it
543
+ return type if type
512
544
 
513
- server
545
+ # Case 2: user accounts can't have application IDs so if one is given, return bot type
546
+ return :bot if application_id
547
+
548
+ # Case 3: bot accounts can't have emails and passwords so if either is given, assume user
549
+ return :user if email || password
550
+
551
+ # Case 4: If we're here and no token is given, throw an exception because that's impossible
552
+ raise ArgumentError, "Can't login because no authentication data was given! Specify at least a token" unless token
553
+
554
+ # Case 5: Only a token has been specified, we can assume it's a bot but we should tell the user
555
+ # to specify the application ID:
556
+ LOGGER.warn('The application ID is missing! Logging in as a bot will work but some OAuth-related functionality will be unavailable!')
557
+ :bot
514
558
  end
515
559
 
516
560
  ### ## ## ######## ######## ######## ## ## ### ## ######
@@ -523,77 +567,97 @@ module Discordrb
523
567
 
524
568
  # Internal handler for PRESENCE_UPDATE
525
569
  def update_presence(data)
570
+ # Friends list presences have no guild ID so ignore these to not cause an error
571
+ return unless data['guild_id']
572
+
526
573
  user_id = data['user']['id'].to_i
527
574
  server_id = data['guild_id'].to_i
528
- server = @servers[server_id]
575
+ server = server(server_id)
529
576
  return unless server
530
577
 
531
- user = @users[user_id]
532
- unless user
533
- user = User.new(data['user'], self)
534
- @users[user_id] = user
535
- end
578
+ member_is_new = false
536
579
 
537
- status = data['status'].to_sym
538
- if status != :offline
539
- unless server.members.find { |u| u.id == user.id }
540
- server.members << user
541
- end
580
+ if server.member_cached?(user_id)
581
+ member = server.member(user_id)
582
+ else
583
+ # If the member is not cached yet, it means that it just came online from not being cached at all
584
+ # due to large_threshold. Fortunately, Discord sends the entire member object in this case, and
585
+ # not just a part of it - we can just cache this member directly
586
+ member = Member.new(data, server, self)
587
+ debug("Implicitly adding presence-obtained member #{user_id} to #{server_id} cache")
588
+
589
+ member_is_new = true
542
590
  end
543
591
 
544
592
  username = data['user']['username']
545
- if username
546
- debug "User changed username: #{user.username} #{username}"
547
- user.update_username(username)
593
+ if username && !member_is_new # Don't set the username for newly-cached members
594
+ debug "Implicitly updating presence-obtained information for member #{user_id}"
595
+ member.update_username(username)
548
596
  end
549
597
 
550
- user.status = status
551
- user.game = data['game'] ? data['game']['name'] : nil
552
- user
598
+ member.status = data['status'].to_sym
599
+ member.game = data['game'] ? data['game']['name'] : nil
600
+
601
+ server.cache_member(member)
553
602
  end
554
603
 
555
604
  # Internal handler for VOICE_STATUS_UPDATE
556
605
  def update_voice_state(data)
557
606
  user_id = data['user_id'].to_i
558
607
  server_id = data['guild_id'].to_i
559
- server = @servers[server_id]
608
+ server = server(server_id)
560
609
  return unless server
561
610
 
562
- user = @users[user_id]
563
- user.server_mute = data['mute']
564
- user.server_deaf = data['deaf']
565
- user.self_mute = data['self_mute']
566
- user.self_deaf = data['self_deaf']
611
+ user = server.member(user_id)
567
612
 
568
613
  channel_id = data['channel_id']
569
614
  channel = nil
570
615
  channel = self.channel(channel_id.to_i) if channel_id
571
- user.move(channel)
616
+
617
+ user.update_voice_state(
618
+ channel,
619
+ data['mute'],
620
+ data['deaf'],
621
+ data['self_mute'],
622
+ data['self_deaf'])
572
623
 
573
624
  @session_id = data['session_id']
574
625
  end
575
626
 
576
627
  # Internal handler for VOICE_SERVER_UPDATE
577
628
  def update_voice_server(data)
578
- debug("Voice server update received! should connect: #{@should_connect_to_voice}")
579
- return unless @should_connect_to_voice
580
- @should_connect_to_voice = false
629
+ server_id = data['guild_id'].to_i
630
+ channel = @should_connect_to_voice[server_id]
631
+
632
+ debug("Voice server update received! chan: #{channel.inspect}")
633
+ return unless channel
634
+ @should_connect_to_voice.delete(server_id)
581
635
  debug('Updating voice server!')
582
636
 
583
637
  token = data['token']
584
638
  endpoint = data['endpoint']
585
- channel = @voice_channel
639
+
640
+ unless endpoint
641
+ debug('VOICE_SERVER_UPDATE sent with nil endpoint! Ignoring')
642
+ return
643
+ end
586
644
 
587
645
  debug('Got data, now creating the bot.')
588
- @voice = Discordrb::Voice::VoiceBot.new(channel, self, token, @session_id, endpoint, @should_encrypt_voice)
646
+ @voices[server_id] = Discordrb::Voice::VoiceBot.new(channel, self, token, @session_id, endpoint, @should_encrypt_voice)
589
647
  end
590
648
 
591
649
  # Internal handler for CHANNEL_CREATE
592
650
  def create_channel(data)
593
651
  channel = Channel.new(data, self)
594
652
  server = channel.server
595
- server.channels << channel
596
- @channels[channel.id] = channel
653
+
654
+ # Handle normal and private channels separately
655
+ if server
656
+ server.channels << channel
657
+ @channels[channel.id] = channel
658
+ else
659
+ @private_channels[channel.id] = channel
660
+ end
597
661
  end
598
662
 
599
663
  # Internal handler for CHANNEL_UPDATE
@@ -608,64 +672,46 @@ module Discordrb
608
672
  def delete_channel(data)
609
673
  channel = Channel.new(data, self)
610
674
  server = channel.server
611
- @channels[channel.id] = nil
612
- server.channels.reject! { |c| c.id == channel.id }
675
+
676
+ # Handle normal and private channels separately
677
+ if server
678
+ @channels.delete(channel.id)
679
+ server.channels.reject! { |c| c.id == channel.id }
680
+ else
681
+ @private_channels.delete(channel.id)
682
+ end
613
683
  end
614
684
 
615
685
  # Internal handler for GUILD_MEMBER_ADD
616
686
  def add_guild_member(data)
617
- user = User.new(data['user'], self)
618
687
  server_id = data['guild_id'].to_i
619
- server = @servers[server_id]
620
-
621
- roles = []
622
- data['roles'].each do |element|
623
- role_id = element.to_i
624
- roles << server.roles.find { |r| r.id == role_id }
625
- end
626
- user.update_roles(server, roles)
627
-
628
- if @users[user.id]
629
- # If the user is already cached, just add the new roles
630
- @users[user.id].merge_roles(server, user.roles[server.id])
631
- else
632
- @users[user.id] = user
633
- end
688
+ server = self.server(server_id)
634
689
 
635
- server.add_user(user)
690
+ member = Member.new(data, server, self)
691
+ server.add_member(member)
636
692
  end
637
693
 
638
694
  # Internal handler for GUILD_MEMBER_UPDATE
639
695
  def update_guild_member(data)
640
- user_id = data['user']['id'].to_i
641
- user = @users[user_id]
642
-
643
696
  server_id = data['guild_id'].to_i
644
- server = @servers[server_id]
697
+ server = self.server(server_id)
645
698
 
646
- roles = []
647
- data['roles'].each do |element|
648
- role_id = element.to_i
649
- roles << server.roles.find { |r| r.id == role_id }
650
- end
651
- user.update_roles(server, roles)
699
+ member = server.member(data['user']['id'].to_i)
700
+ member.update_roles(data['roles'])
652
701
  end
653
702
 
654
703
  # Internal handler for GUILD_MEMBER_DELETE
655
704
  def delete_guild_member(data)
656
- user_id = data['user']['id'].to_i
657
- user = @users[user_id]
658
-
659
705
  server_id = data['guild_id'].to_i
660
- server = @servers[server_id]
706
+ server = self.server(server_id)
661
707
 
662
- user.delete_roles(server_id)
663
- server.delete_user(user_id)
708
+ user_id = data['user']['id'].to_i
709
+ server.delete_member(user_id)
664
710
  end
665
711
 
666
712
  # Internal handler for GUILD_CREATE
667
713
  def create_guild(data)
668
- add_server(data)
714
+ ensure_server(data)
669
715
  end
670
716
 
671
717
  # Internal handler for GUILD_UPDATE
@@ -738,60 +784,88 @@ module Discordrb
738
784
  ## ## ## ## ## ## ## ###
739
785
  ######## ####### ###### #### ## ##
740
786
 
741
- def login
742
- if @email == :token
743
- debug('Logging in using static token')
787
+ def login(type, email, password, token, token_cache)
788
+ # Don't bother with any login code if a token is already specified
789
+ return process_token(type, token) if token
744
790
 
745
- # The password is the token!
746
- return @password
747
- end
791
+ # If a bot account attempts logging in without a token, throw an error
792
+ raise ArgumentError, 'Bot account detected (type == :bot) but no token was found! Please specify a token in the Bot initializer, or use a user account.' if type == :bot
748
793
 
749
- debug('Logging in')
750
- login_attempts ||= 0
794
+ # If the type is not a user account at this point, it must be invalid
795
+ raise ArgumentError, 'Invalid type specified! Use either :bot or :user' if type == :user
751
796
 
752
- # First, attempt to get the token from the cache
753
- token = @token_cache.token(@email, @password)
754
- if token
755
- debug('Token successfully obtained from cache!')
756
- return token
757
- end
797
+ user_login(email, password, token_cache)
798
+ end
758
799
 
759
- # Login
760
- login_response = API.login(@email, @password)
761
- raise Discordrb::Errors::HTTPStatusError, login_response.code if login_response.code >= 400
800
+ def process_token(type, token)
801
+ # Remove the "Bot " prefix if it exists
802
+ token = token[4..-1] if token.start_with? 'Bot '
762
803
 
763
- # Parse response
764
- login_response_object = JSON.parse(login_response)
765
- raise Discordrb::Errors::InvalidAuthenticationError unless login_response_object['token']
804
+ token = 'Bot ' + token unless type == :user
805
+ token
806
+ end
807
+
808
+ def user_login(email, password, token_cache)
809
+ debug('Logging in')
766
810
 
811
+ # Attempt to retrieve the token from the cache
812
+ retrieved_token = retrieve_token(email, password, token_cache)
813
+ return retrieved_token if retrieved_token
814
+
815
+ login_attempts ||= 0
816
+
817
+ # Login
818
+ login_response = JSON.parse(API.login(email, password))
819
+ token = login_response['token']
820
+ raise Discordrb::Errors::InvalidAuthenticationError unless token
767
821
  debug('Received token from Discord!')
768
822
 
769
823
  # Cache the token
770
- @token_cache.store_token(@email, @password, login_response_object['token'])
771
-
772
- login_response_object['token']
773
- rescue Exception => e
774
- response_code = login_response.nil? ? 0 : login_response.code ######## mackmm145
775
- if login_attempts < 100 && (e.inspect.include?('No such host is known.') || response_code == 523)
776
- debug("Login failed! Reattempting in 5 seconds. #{100 - login_attempts} attempts remaining.")
777
- debug("Error was: #{e.inspect}")
778
- sleep 5
779
- login_attempts += 1
780
- retry
824
+ token_cache.store_token(email, password, token)
825
+
826
+ token
827
+ rescue RestClient::BadRequest
828
+ raise Discordrb::Errors::InvalidAuthenticationError
829
+ rescue SocketError, RestClient::RequestFailed => e # RequestFailed handles the 52x error codes Cloudflare sometimes sends that aren't covered by specific RestClient classes
830
+ if login_attempts && login_attempts > 100
831
+ LOGGER.error("User login failed permanently after #{login_attempts} attempts")
832
+ raise
781
833
  else
782
- debug("Login failed permanently after #{login_attempts + 1} attempts")
783
-
784
- # Apparently we get a 400 if the password or username is incorrect. In that case, tell the user
785
- debug("Are you sure you're using the correct username and password?") if e.class == RestClient::BadRequest
786
- log_exception(e)
787
- raise $ERROR_INFO
834
+ LOGGER.error("User login failed! Trying again in 5 seconds, #{100 - login_attempts} remaining")
835
+ LOGGER.log_exception(e)
836
+ retry
788
837
  end
789
838
  end
790
839
 
840
+ def retrieve_token(email, password, token_cache)
841
+ # First, attempt to get the token from the cache
842
+ token = token_cache.token(email, password)
843
+ debug('Token successfully obtained from cache!') if token
844
+ token
845
+ end
846
+
791
847
  def find_gateway
792
- # Get updated websocket_hub
793
- response = API.gateway(token)
794
- JSON.parse(response)['url']
848
+ # If the reconnect URL is set, it means we got an op 7 earlier and should reconnect to the new URL
849
+ if @reconnect_url
850
+ debug("Reconnecting to URL #{@reconnect_url}")
851
+ url = @reconnect_url
852
+ @reconnect_url = nil # Unset the URL so we don't connect to the same URL again if the connection fails
853
+ url
854
+ else
855
+ # Get the correct gateway URL from Discord
856
+ response = API.gateway(token)
857
+ JSON.parse(response)['url']
858
+ end
859
+ end
860
+
861
+ def process_gateway
862
+ raw_url = find_gateway
863
+
864
+ # Append a slash in case it's not there (I'm not sure how well WSCS handles it otherwise)
865
+ raw_url += '/' unless raw_url.end_with? '/'
866
+
867
+ # Add the parameters we want
868
+ raw_url + "?encoding=json&v=#{GATEWAY_VERSION}"
795
869
  end
796
870
 
797
871
  ## ## ###### ######## ## ## ######## ## ## ######## ######
@@ -802,106 +876,168 @@ module Discordrb
802
876
  ## ## ## ## ## ## ## ## ## ## ### ## ## ##
803
877
  #### ### ###### ######## ### ######## ## ## ## ######
804
878
 
879
+ # Desired gateway version
880
+ GATEWAY_VERSION = 4
881
+
805
882
  def websocket_connect
806
883
  debug('Attempting to get gateway URL...')
807
- websocket_hub = find_gateway
808
- debug("Success! Gateway URL is #{websocket_hub}.")
884
+ gateway_url = process_gateway
885
+ debug("Success! Gateway URL is #{gateway_url}.")
809
886
  debug('Now running bot')
810
887
 
811
- EM.run do
812
- @ws = Faye::WebSocket::Client.new(websocket_hub)
888
+ @ws = Discordrb::WebSocket.new(
889
+ gateway_url,
890
+ method(:websocket_open),
891
+ method(:websocket_message),
892
+ method(:websocket_close),
893
+ proc { |e| LOGGER.error "Gateway error: #{e}" }
894
+ )
895
+
896
+ @ws.thread[:discordrb_name] = 'gateway'
897
+ @ws.thread.join
898
+ rescue => e
899
+ LOGGER.error 'Error while connecting to the gateway!'
900
+ LOGGER.log_exception e
901
+ raise
902
+ end
813
903
 
814
- @ws.on(:open) { |event| websocket_open(event) }
815
- @ws.on(:message) { |event| websocket_message(event) }
816
- @ws.on(:error) { |event| debug(event.message) }
817
- @ws.on :close do |event|
818
- websocket_close(event)
819
- @ws = nil
820
- end
821
- end
904
+ def websocket_reconnect(url)
905
+ # In here, we do nothing except set the reconnect URL and close the current connection.
906
+ @reconnect_url = url
907
+ @ws.close
908
+
909
+ # Reset the packet sequence number so we don't try to resume the connection afterwards
910
+ @sequence = 0
911
+
912
+ # Let's hope the reconnect handler reconnects us correctly...
822
913
  end
823
914
 
824
915
  def websocket_message(event)
916
+ if event.byteslice(0) == 'x'
917
+ # The message is encrypted
918
+ event = Zlib::Inflate.inflate(event)
919
+ end
920
+
825
921
  # Parse packet
826
- packet = JSON.parse(event.data)
922
+ packet = JSON.parse(event)
827
923
 
828
924
  if @prevent_ready && packet['t'] == 'READY'
829
925
  debug('READY packet was received and suppressed')
830
926
  elsif @prevent_ready && packet['t'] == 'GUILD_MEMBERS_CHUNK'
831
927
  # Ignore chunks as they will be handled later anyway
832
928
  else
833
- LOGGER.in(event.data.to_s)
929
+ LOGGER.in(event.to_s)
930
+ end
931
+
932
+ opcode = packet['op'].to_i
933
+
934
+ if opcode == Opcodes::HEARTBEAT
935
+ # If Discord sends us a heartbeat, simply reply with a heartbeat with the packet's sequence number
936
+ @sequence = packet['s'].to_i
937
+
938
+ LOGGER.info("Received an op1 (seq: #{@sequence})! This means another client connected while this one is already running. Replying with the same seq")
939
+ send_heartbeat
940
+
941
+ return
834
942
  end
835
943
 
836
- raise 'Invalid Packet' unless packet['op'] == 0 # TODO
944
+ if opcode == Opcodes::RECONNECT
945
+ websocket_reconnect(packet['d'] ? packet['d']['url'] : nil)
946
+ return
947
+ end
948
+
949
+ if opcode == Opcodes::INVALIDATE_SESSION
950
+ LOGGER.info "We got an opcode 9 from Discord! Invalidating the session. You probably don't have to worry about this."
951
+ invalidate_session
952
+ LOGGER.debug 'Session invalidated!'
953
+
954
+ LOGGER.debug 'Reconnecting with IDENTIFY'
955
+ websocket_open # Since we just invalidated the session, pretending we just opened the WS again will re-identify
956
+ LOGGER.debug "Re-identified! Let's hope everything works fine."
957
+ return
958
+ end
959
+
960
+ raise "Got an unexpected opcode (#{opcode}) in a gateway event!
961
+ Please report this issue along with the following information:
962
+ v#{GATEWAY_VERSION} #{packet}" unless opcode == Opcodes::DISPATCH
963
+
964
+ # Check whether there are still unavailable servers and there have been more than 10 seconds since READY
965
+ if @unavailable_servers && @unavailable_servers > 0 && (Time.now - @ready_time) > 10
966
+ # The server streaming timed out!
967
+ LOGGER.warn("Server streaming timed out with #{@unavailable_servers} servers remaining")
968
+ LOGGER.warn("This means some servers are unavailable due to an outage. Notifying ready now, we'll have to live without these servers")
969
+ notify_ready
970
+ end
971
+
972
+ # Keep track of the packet sequence (continually incrementing number for every packet) so we can resume a
973
+ # connection if we disconnect
974
+ @sequence = packet['s'].to_i
837
975
 
838
976
  data = packet['d']
839
977
  type = packet['t'].intern
840
978
  case type
841
979
  when :READY
980
+ LOGGER.info("Discord using gateway protocol version: #{data['v']}, requested: #{GATEWAY_VERSION}")
981
+
982
+ # Set the session ID in case we get disconnected and have to resume
983
+ @session_id = data['session_id']
984
+
842
985
  # Activate the heartbeats
843
986
  @heartbeat_interval = data['heartbeat_interval'].to_f / 1000.0
844
987
  @heartbeat_active = true
845
988
  debug("Desired heartbeat_interval: #{@heartbeat_interval}")
846
989
 
847
- bot_user_id = data['user']['id'].to_i
848
990
  @profile = Profile.new(data['user'], self, @email, @password)
849
991
 
850
992
  # Initialize servers
851
993
  @servers = {}
994
+
995
+ # Count unavailable servers
996
+ @unavailable_servers = 0
997
+
852
998
  data['guilds'].each do |element|
853
- add_server(element)
999
+ # Check for true specifically because unavailable=false indicates that a previously unavailable server has
1000
+ # come online
1001
+ if element['unavailable'].is_a? TrueClass
1002
+ @unavailable_servers += 1
1003
+
1004
+ # Ignore any unavailable servers
1005
+ next
1006
+ end
854
1007
 
855
- # Save the bot user
856
- @bot_user = @users[bot_user_id]
1008
+ ensure_server(element)
857
1009
  end
858
1010
 
859
1011
  # Add private channels
860
- @private_channels = {}
861
1012
  data['private_channels'].each do |element|
862
- channel = Channel.new(element, self)
863
- @channels[channel.id] = channel
1013
+ channel = ensure_channel(element)
864
1014
  @private_channels[channel.recipient.id] = channel
865
1015
  end
866
1016
 
867
- # Make sure to raise the event
868
- raise_event(ReadyEvent.new)
1017
+ # Don't notify yet if there are unavailable servers because they need to get available before the bot truly has
1018
+ # all the data
1019
+ if @unavailable_servers == 0
1020
+ # No unavailable servers - we're ready!
1021
+ notify_ready
1022
+ end
1023
+
1024
+ @ready_time = Time.now
1025
+ when :RESUMED
1026
+ # The RESUMED event is received after a successful op 6 (resume). It does nothing except tell the bot the
1027
+ # connection is initiated (like READY would) and set a new heartbeat interval.
1028
+ debug('Connection resumed')
869
1029
 
870
- # Afterwards, send out a members request to get the chunk data
871
- chunk_packet = {
872
- op: 8,
873
- d: {
874
- guild_id: @servers.keys,
875
- query: '',
876
- limit: 0
877
- }
878
- }.to_json
879
- @ws.send(chunk_packet)
1030
+ @heartbeat_interval = data['heartbeat_interval'].to_f / 1000.0
880
1031
 
881
- LOGGER.good 'Ready'
1032
+ # Since we disabled it earlier so we don't send any heartbeats in between close and resume,, make sure to
1033
+ # re-enable heartbeating
1034
+ @heartbeat_active = true
882
1035
 
883
- # Tell the run method that everything was successful
884
- @ws_success = true
1036
+ debug("Desired heartbeat_interval: #{@heartbeat_interval}")
885
1037
  when :GUILD_MEMBERS_CHUNK
886
1038
  id = data['guild_id'].to_i
887
- members = data['members']
888
-
889
- start_time = Time.now
890
-
891
- members.each do |member|
892
- # Add the guild_id to the member so we can reuse add_guild_member
893
- member['guild_id'] = id
894
-
895
- add_guild_member(member)
896
- end
897
-
898
- duration = Time.now - start_time
899
-
900
- if members.length < 1000
901
- debug "Got final chunk for server #{id}, parsing took #{duration} seconds"
902
- else
903
- debug "Got one chunk for server #{id}, parsing took #{duration} seconds"
904
- end
1039
+ server = server(id)
1040
+ server.process_chunk(data['members'])
905
1041
  when :MESSAGE_CREATE
906
1042
  create_message(data)
907
1043
 
@@ -912,7 +1048,7 @@ module Discordrb
912
1048
  event = MessageEvent.new(message, self)
913
1049
  raise_event(event)
914
1050
 
915
- if message.mentions.any? { |user| user.id == @bot_user.id }
1051
+ if message.mentions.any? { |user| user.id == @profile.id }
916
1052
  event = MentionEvent.new(message, self)
917
1053
  raise_event(event)
918
1054
  end
@@ -924,7 +1060,10 @@ module Discordrb
924
1060
  when :MESSAGE_UPDATE
925
1061
  update_message(data)
926
1062
 
927
- event = MessageEditEvent.new(data, self)
1063
+ message = Message.new(data, self)
1064
+ return if message.from_bot? && !should_parse_self
1065
+
1066
+ event = MessageEditEvent.new(message, self)
928
1067
  raise_event(event)
929
1068
  when :MESSAGE_DELETE
930
1069
  delete_message(data)
@@ -941,8 +1080,11 @@ module Discordrb
941
1080
  debug 'Typing started in channel the bot has no access to, ignoring'
942
1081
  end
943
1082
  when :PRESENCE_UPDATE
1083
+ # Ignore friends list presences
1084
+ return unless data['guild_id']
1085
+
944
1086
  now_playing = data['game']
945
- presence_user = user(data['user']['id'].to_i)
1087
+ presence_user = @users[data['user']['id'].to_i]
946
1088
  played_before = presence_user.nil? ? nil : presence_user.game
947
1089
  update_presence(data)
948
1090
 
@@ -980,17 +1122,17 @@ module Discordrb
980
1122
  when :GUILD_MEMBER_ADD
981
1123
  add_guild_member(data)
982
1124
 
983
- event = GuildMemberAddEvent.new(data, self)
1125
+ event = ServerMemberAddEvent.new(data, self)
984
1126
  raise_event(event)
985
1127
  when :GUILD_MEMBER_UPDATE
986
1128
  update_guild_member(data)
987
1129
 
988
- event = GuildMemberUpdateEvent.new(data, self)
1130
+ event = ServerMemberUpdateEvent.new(data, self)
989
1131
  raise_event(event)
990
1132
  when :GUILD_MEMBER_REMOVE
991
1133
  delete_guild_member(data)
992
1134
 
993
- event = GuildMemberDeleteEvent.new(data, self)
1135
+ event = ServerMemberDeleteEvent.new(data, self)
994
1136
  raise_event(event)
995
1137
  when :GUILD_BAN_ADD
996
1138
  add_user_ban(data)
@@ -1005,56 +1147,106 @@ module Discordrb
1005
1147
  when :GUILD_ROLE_UPDATE
1006
1148
  update_guild_role(data)
1007
1149
 
1008
- event = GuildRoleUpdateEvent.new(data, self)
1150
+ event = ServerRoleUpdateEvent.new(data, self)
1009
1151
  raise_event(event)
1010
1152
  when :GUILD_ROLE_CREATE
1011
1153
  create_guild_role(data)
1012
1154
 
1013
- event = GuildRoleCreateEvent.new(data, self)
1155
+ event = ServerRoleCreateEvent.new(data, self)
1014
1156
  raise_event(event)
1015
1157
  when :GUILD_ROLE_DELETE
1016
1158
  delete_guild_role(data)
1017
1159
 
1018
- event = GuildRoleDeleteEvent.new(data, self)
1160
+ event = ServerRoleDeleteEvent.new(data, self)
1019
1161
  raise_event(event)
1020
1162
  when :GUILD_CREATE
1021
1163
  create_guild(data)
1022
1164
 
1023
- event = GuildCreateEvent.new(data, self)
1165
+ # Check for false specifically (no data means the server has never been unavailable)
1166
+ if data['unavailable'].is_a? FalseClass
1167
+ @unavailable_servers -= 1
1168
+
1169
+ notify_ready if @unavailable_servers == 0
1170
+ end
1171
+
1172
+ event = ServerCreateEvent.new(data, self)
1024
1173
  raise_event(event)
1025
1174
  when :GUILD_UPDATE
1026
1175
  update_guild(data)
1027
1176
 
1028
- event = GuildUpdateEvent.new(data, self)
1177
+ event = ServerUpdateEvent.new(data, self)
1029
1178
  raise_event(event)
1030
1179
  when :GUILD_DELETE
1031
1180
  delete_guild(data)
1032
1181
 
1033
- event = GuildDeleteEvent.new(data, self)
1182
+ event = ServerDeleteEvent.new(data, self)
1034
1183
  raise_event(event)
1035
1184
  else
1036
1185
  # another event that we don't support yet
1037
1186
  debug "Event #{packet['t']} has been received but is unsupported, ignoring"
1038
1187
  end
1039
1188
  rescue Exception => e
1189
+ LOGGER.error('Gateway message error!')
1040
1190
  log_exception(e)
1041
1191
  end
1042
1192
 
1043
1193
  def websocket_close(event)
1044
1194
  LOGGER.error('Disconnected from WebSocket!')
1045
- LOGGER.error(" (Reason: #{event.reason})")
1046
- LOGGER.error(" (Code: #{event.code})")
1047
- raise_event(DisconnectEvent.new)
1048
- EM.stop
1195
+
1196
+ # Don't handle nil events (for example if the disconnect came from our side)
1197
+ return unless event
1198
+
1199
+ # Handle actual close frames and errors separately
1200
+ if event.respond_to? :code
1201
+ LOGGER.error(" (Reason: #{event.data})")
1202
+ LOGGER.error(" (Code: #{event.code})")
1203
+ else
1204
+ LOGGER.log_exception event
1205
+ end
1206
+
1207
+ if event.code.to_i == 4006
1208
+ # If we got disconnected with a 4006, it means we sent a resume when Discord wanted an identify. To battle this,
1209
+ # we invalidate the local session so we'll just send an identify next time
1210
+ debug('Apparently we just sent the wrong type of initiation packet (resume rather than identify) to Discord. (Sorry!)
1211
+ Invalidating session so this is fixed next time')
1212
+ invalidate_session
1213
+ end
1214
+
1215
+ raise_event(DisconnectEvent.new(self))
1216
+
1217
+ # Stop sending heartbeats
1218
+ @heartbeat_active = false
1219
+
1220
+ # Safely close the WS connection and handle any errors that occur there
1221
+ begin
1222
+ @ws.close
1223
+ rescue => e
1224
+ LOGGER.warn 'Got the following exception while closing the WS after being disconnected:'
1225
+ LOGGER.log_exception e
1226
+ end
1227
+ rescue => e
1228
+ LOGGER.log_exception e
1229
+ raise
1049
1230
  end
1050
1231
 
1051
- def websocket_open(_)
1232
+ def websocket_open
1233
+ # If we've already received packets (packet sequence > 0) resume an existing connection instead of identifying anew
1234
+ if @sequence && @sequence > 0
1235
+ resume(@sequence, raw_token, @session_id)
1236
+ return
1237
+ end
1238
+
1239
+ identify(raw_token, 100, GATEWAY_VERSION)
1240
+ end
1241
+
1242
+ # Identify the client to the gateway
1243
+ def identify(token, large_threshold, version)
1052
1244
  # Send the initial packet
1053
1245
  packet = {
1054
- op: 2, # Packet identifier
1055
- d: { # Packet data
1056
- v: 3, # WebSocket protocol version
1057
- token: @token,
1246
+ op: Opcodes::IDENTIFY, # Opcode
1247
+ d: { # Packet data
1248
+ v: version, # WebSocket protocol version
1249
+ token: token,
1058
1250
  properties: { # I'm unsure what these values are for exactly, but they don't appear to impact bot functionality in any way.
1059
1251
  :'$os' => RUBY_PLATFORM.to_s,
1060
1252
  :'$browser' => 'discordrb',
@@ -1062,19 +1254,62 @@ module Discordrb
1062
1254
  :'$referrer' => '',
1063
1255
  :'$referring_domain' => ''
1064
1256
  },
1065
- large_threshold: 100
1257
+ large_threshold: large_threshold,
1258
+ compress: true
1066
1259
  }
1067
1260
  }
1068
1261
 
1069
1262
  @ws.send(packet.to_json)
1070
1263
  end
1071
1264
 
1072
- def send_heartbeat
1073
- millis = Time.now.strftime('%s%L').to_i
1074
- LOGGER.out("Sending heartbeat at #{millis}")
1265
+ # Resume a previous gateway connection when reconnecting to a different server
1266
+ def resume(seq, token, session_id)
1267
+ data = {
1268
+ op: Opcodes::RESUME,
1269
+ d: {
1270
+ seq: seq,
1271
+ token: token,
1272
+ session_id: session_id
1273
+ }
1274
+ }
1275
+
1276
+ @ws.send(data.to_json)
1277
+ end
1278
+
1279
+ # Invalidate the current session (whatever this means)
1280
+ def invalidate_session
1281
+ @sequence = 0
1282
+ @session_id = nil
1283
+ end
1284
+
1285
+ # Notifies everything there is to be notified that the connection is now ready
1286
+ def notify_ready
1287
+ # Make sure to raise the event
1288
+ raise_event(ReadyEvent.new(self))
1289
+ LOGGER.good 'Ready'
1290
+
1291
+ # Tell the run method that everything was successful
1292
+ @ws_success = true
1293
+ end
1294
+
1295
+ # Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
1296
+ # unexpected way
1297
+ def wait_for_reconnect
1298
+ # We disconnected in an unexpected way! Wait before reconnecting so we don't spam Discord's servers.
1299
+ debug("Disconnected! Attempting to reconnect in #{@falloff} seconds.")
1300
+ sleep @falloff
1301
+
1302
+ # Calculate new falloff
1303
+ @falloff *= 1.5
1304
+ @falloff = 115 + (rand * 10) if @falloff > 120 # Cap the falloff at 120 seconds and then add some random jitter
1305
+ end
1306
+
1307
+ def send_heartbeat(sequence = nil)
1308
+ sequence ||= @sequence
1309
+ LOGGER.out("Sending heartbeat with sequence #{sequence}")
1075
1310
  data = {
1076
- op: 1,
1077
- d: millis
1311
+ op: Opcodes::HEARTBEAT,
1312
+ d: sequence
1078
1313
  }
1079
1314
 
1080
1315
  @ws.send(data.to_json)