mij-discord 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +6 -0
  5. data/LICENSE +21 -0
  6. data/README.md +35 -0
  7. data/Rakefile +10 -0
  8. data/bin/console +7 -0
  9. data/bin/setup +6 -0
  10. data/lib/mij-discord.rb +56 -0
  11. data/lib/mij-discord/bot.rb +579 -0
  12. data/lib/mij-discord/cache.rb +298 -0
  13. data/lib/mij-discord/core/api.rb +228 -0
  14. data/lib/mij-discord/core/api/channel.rb +416 -0
  15. data/lib/mij-discord/core/api/invite.rb +43 -0
  16. data/lib/mij-discord/core/api/server.rb +465 -0
  17. data/lib/mij-discord/core/api/user.rb +144 -0
  18. data/lib/mij-discord/core/errors.rb +106 -0
  19. data/lib/mij-discord/core/gateway.rb +505 -0
  20. data/lib/mij-discord/data.rb +65 -0
  21. data/lib/mij-discord/data/application.rb +38 -0
  22. data/lib/mij-discord/data/channel.rb +404 -0
  23. data/lib/mij-discord/data/embed.rb +115 -0
  24. data/lib/mij-discord/data/emoji.rb +62 -0
  25. data/lib/mij-discord/data/invite.rb +87 -0
  26. data/lib/mij-discord/data/member.rb +174 -0
  27. data/lib/mij-discord/data/message.rb +206 -0
  28. data/lib/mij-discord/data/permissions.rb +121 -0
  29. data/lib/mij-discord/data/role.rb +99 -0
  30. data/lib/mij-discord/data/server.rb +359 -0
  31. data/lib/mij-discord/data/user.rb +173 -0
  32. data/lib/mij-discord/data/voice.rb +68 -0
  33. data/lib/mij-discord/events.rb +133 -0
  34. data/lib/mij-discord/events/basic.rb +80 -0
  35. data/lib/mij-discord/events/channel.rb +50 -0
  36. data/lib/mij-discord/events/member.rb +66 -0
  37. data/lib/mij-discord/events/message.rb +150 -0
  38. data/lib/mij-discord/events/server.rb +102 -0
  39. data/lib/mij-discord/logger.rb +20 -0
  40. data/lib/mij-discord/version.rb +5 -0
  41. data/mij-discord.gemspec +31 -0
  42. metadata +154 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MijDiscord::Core::API::User
4
+ class << self
5
+ # Get user data
6
+ # https://discordapp.com/developers/docs/resources/user#get-user
7
+ def resolve(token, user_id)
8
+ MijDiscord::Core::API.request(
9
+ :users_uid,
10
+ nil,
11
+ :get,
12
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/#{user_id}",
13
+ Authorization: token
14
+ )
15
+ end
16
+
17
+ # Get profile data
18
+ # https://discordapp.com/developers/docs/resources/user#get-current-user
19
+ def profile(token)
20
+ MijDiscord::Core::API.request(
21
+ :users_me,
22
+ nil,
23
+ :get,
24
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me",
25
+ Authorization: token
26
+ )
27
+ end
28
+
29
+ # Change the current bot's nickname on a server
30
+ def change_own_nickname(token, server_id, nick, reason = nil)
31
+ MijDiscord::Core::API.request(
32
+ :guilds_sid_members_me_nick,
33
+ server_id, # This is technically a guild endpoint
34
+ :patch,
35
+ "#{MijDiscord::Core::API::APIBASE_URL}/guilds/#{server_id}/members/@me/nick",
36
+ { nick: nick }.to_json,
37
+ Authorization: token,
38
+ content_type: :json,
39
+ 'X-Audit-Log-Reason': reason
40
+ )
41
+ end
42
+
43
+ # Update user data
44
+ # https://discordapp.com/developers/docs/resources/user#modify-current-user
45
+ def update_profile(token, username, avatar)
46
+ MijDiscord::Core::API.request(
47
+ :users_me,
48
+ nil,
49
+ :patch,
50
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me",
51
+ { avatar: avatar, username: username }.delete_if {|_,v| v.nil? }.to_json,
52
+ Authorization: token,
53
+ content_type: :json
54
+ )
55
+ end
56
+
57
+ # Get the servers a user is connected to
58
+ # https://discordapp.com/developers/docs/resources/user#get-current-user-guilds
59
+ def servers(token)
60
+ MijDiscord::Core::API.request(
61
+ :users_me_guilds,
62
+ nil,
63
+ :get,
64
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/guilds",
65
+ Authorization: token
66
+ )
67
+ end
68
+
69
+ # Leave a server
70
+ # https://discordapp.com/developers/docs/resources/user#leave-guild
71
+ def leave_server(token, server_id)
72
+ MijDiscord::Core::API.request(
73
+ :users_me_guilds_sid,
74
+ nil,
75
+ :delete,
76
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/guilds/#{server_id}",
77
+ Authorization: token
78
+ )
79
+ end
80
+
81
+ # Get the DMs for the current user
82
+ # https://discordapp.com/developers/docs/resources/user#get-user-dms
83
+ def user_dms(token)
84
+ MijDiscord::Core::API.request(
85
+ :users_me_channels,
86
+ nil,
87
+ :get,
88
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/channels",
89
+ Authorization: token
90
+ )
91
+ end
92
+
93
+ # Create a DM to another user
94
+ # https://discordapp.com/developers/docs/resources/user#create-dm
95
+ def create_pm(token, recipient_id)
96
+ MijDiscord::Core::API.request(
97
+ :users_me_channels,
98
+ nil,
99
+ :post,
100
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/channels",
101
+ { recipient_id: recipient_id }.to_json,
102
+ Authorization: token,
103
+ content_type: :json
104
+ )
105
+ end
106
+
107
+ # Get information about a user's connections
108
+ # https://discordapp.com/developers/docs/resources/user#get-users-connections
109
+ def connections(token)
110
+ MijDiscord::Core::API.request(
111
+ :users_me_connections,
112
+ nil,
113
+ :get,
114
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/connections",
115
+ Authorization: token
116
+ )
117
+ end
118
+
119
+ # Change user status setting
120
+ def change_status_setting(token, status)
121
+ MijDiscord::Core::API.request(
122
+ :users_me_settings,
123
+ nil,
124
+ :patch,
125
+ "#{MijDiscord::Core::API::APIBASE_URL}/users/@me/settings",
126
+ { status: status }.to_json,
127
+ Authorization: token,
128
+ content_type: :json
129
+ )
130
+ end
131
+
132
+ # Returns one of the "default" discord avatars from the CDN given a discriminator
133
+ def default_avatar(discrim = 0)
134
+ index = discrim.to_i % 5
135
+ "#{MijDiscord::Core::API::CDN_URL}/embed/avatars/#{index}.png"
136
+ end
137
+
138
+ # Make an avatar URL from the user and avatar IDs
139
+ def avatar_url(user_id, avatar_id, format = nil)
140
+ format ||= avatar_id.start_with?('a_') ? :gif : :webp
141
+ "#{MijDiscord::Core::API::CDN_URL}/avatars/#{user_id}/#{avatar_id}.#{format}"
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MijDiscord::Core::Errors
4
+ class InvalidAuthentication < RuntimeError; end
5
+
6
+ class MessageTooLong < RuntimeError; end
7
+
8
+ class NoPermission < RuntimeError; end
9
+
10
+ class CloudflareError < RuntimeError; end
11
+
12
+ class CodeError < RuntimeError
13
+ class << self
14
+ attr_reader :code
15
+
16
+ def define(code)
17
+ klass = Class.new(CodeError)
18
+ klass.instance_variable_set('@code', code)
19
+
20
+ @code_classes ||= {}
21
+ @code_classes[code] = klass
22
+
23
+ klass
24
+ end
25
+
26
+ def resolve(code)
27
+ @code_classes ||= {}
28
+ @code_classes[code]
29
+ end
30
+ end
31
+
32
+ def code
33
+ self.class.code
34
+ end
35
+ end
36
+
37
+ UnknownError = CodeError.define(0)
38
+
39
+ UnknownAccount = CodeError.define(10_001)
40
+
41
+ UnknownApplication = CodeError.define(10_102)
42
+
43
+ UnknownChannel = CodeError.define(10_103)
44
+
45
+ UnknownServer = CodeError.define(10_004)
46
+
47
+ UnknownIntegration = CodeError.define(10_005)
48
+
49
+ UnknownInvite = CodeError.define(10_006)
50
+
51
+ UnknownMember = CodeError.define(10_007)
52
+
53
+ UnknownMessage = CodeError.define(10_008)
54
+
55
+ UnknownOverwrite = CodeError.define(10_009)
56
+
57
+ UnknownProvider = CodeError.define(10_010)
58
+
59
+ UnknownRole = CodeError.define(10_011)
60
+
61
+ UnknownToken = CodeError.define(10_012)
62
+
63
+ UnknownUser = CodeError.define(10_013)
64
+
65
+ EndpointNotForBots = CodeError.define(20_001)
66
+
67
+ EndpointOnlyForBots = CodeError.define(20_002)
68
+
69
+ ServerLimitReached = CodeError.define(30_001)
70
+
71
+ FriendLimitReached = CodeError.define(30_002)
72
+
73
+ Unauthorized = CodeError.define(40_001)
74
+
75
+ MissingAccess = CodeError.define(50_001)
76
+
77
+ InvalidAccountType = CodeError.define(50_002)
78
+
79
+ InvalidForDM = CodeError.define(50_003)
80
+
81
+ EmbedDisabled = CodeError.define(50_004)
82
+
83
+ MessageAuthoredByOtherUser = CodeError.define(50_005)
84
+
85
+ MessageEmpty = CodeError.define(50_006)
86
+
87
+ NoMessagesToUser = CodeError.define(50_007)
88
+
89
+ NoMessagesInVoiceChannel = CodeError.define(50_008)
90
+
91
+ VerificationLevelTooHigh = CodeError.define(50_009)
92
+
93
+ NoBotForApplication = CodeError.define(50_010)
94
+
95
+ ApplicationLimitReached = CodeError.define(50_011)
96
+
97
+ InvalidOAuthState = CodeError.define(50_012)
98
+
99
+ MissingPermissions = CodeError.define(50_013)
100
+
101
+ InvalidAuthToken = CodeError.define(50_014)
102
+
103
+ NoteTooLong = CodeError.define(50_015)
104
+
105
+ InvalidBulkDeleteCount = CodeError.define(50_016)
106
+ end
@@ -0,0 +1,505 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MijDiscord::Core
4
+ # Gateway packet opcodes
5
+ module Opcodes
6
+ # **Received** when Discord dispatches an event to the gateway (like MESSAGE_CREATE, PRESENCE_UPDATE or whatever).
7
+ # The vast majority of received packets will have this opcode.
8
+ DISPATCH = 0
9
+
10
+ # **Two-way**: The client has to send a packet with this opcode every ~40 seconds (actual interval specified in
11
+ # READY or RESUMED) and the current sequence number, otherwise it will be disconnected from the gateway. In certain
12
+ # cases Discord may also send one, specifically if two clients are connected at once.
13
+ HEARTBEAT = 1
14
+
15
+ # **Sent**: This is one of the two possible ways to initiate a session after connecting to the gateway. It
16
+ # should contain the authentication token along with other stuff the server has to know right from the start, such
17
+ # as large_threshold and, for older gateway versions, the desired version.
18
+ IDENTIFY = 2
19
+
20
+ # **Sent**: Packets with this opcode are used to change the user's status and played game. (Sending this is never
21
+ # necessary for a gateway client to behave correctly)
22
+ PRESENCE = 3
23
+
24
+ # **Sent**: Packets with this opcode are used to change a user's voice state (mute/deaf/unmute/undeaf/etc.). It is
25
+ # also used to connect to a voice server in the first place. (Sending this is never necessary for a gateway client
26
+ # to behave correctly)
27
+ VOICE_STATE = 4
28
+
29
+ # **Sent**: This opcode is used to ping a voice server, whatever that means. The functionality of this opcode isn't
30
+ # known well but non-user clients should never send it.
31
+ VOICE_PING = 5
32
+
33
+ # **Sent**: This is the other of two possible ways to initiate a gateway session (other than {IDENTIFY}). Rather
34
+ # than starting an entirely new session, it resumes an existing session by replaying all events from a given
35
+ # sequence number. It should be used to recover from a connection error or anything like that when the session is
36
+ # still valid - sending this with an invalid session will cause an error to occur.
37
+ RESUME = 6
38
+
39
+ # **Received**: Discord sends this opcode to indicate that the client should reconnect to a different gateway
40
+ # server because the old one is currently being decommissioned. Counterintuitively, this opcode also invalidates the
41
+ # session - the client has to create an entirely new session with the new gateway instead of resuming the old one.
42
+ RECONNECT = 7
43
+
44
+ # **Sent**: This opcode identifies packets used to retrieve a list of members from a particular server. There is
45
+ # also a REST endpoint available for this, but it is inconvenient to use because the client has to implement
46
+ # pagination itself, whereas sending this opcode lets Discord handle the pagination and the client can just add
47
+ # members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
48
+ REQUEST_MEMBERS = 8
49
+
50
+ # **Received**: Sent by Discord when the session becomes invalid for any reason. This may include improperly
51
+ # resuming existing sessions, attempting to start sessions with invalid data, or something else entirely. The client
52
+ # should handle this by simply starting a new session.
53
+ INVALIDATE_SESSION = 9
54
+
55
+ # **Received**: Sent immediately for any opened connection; tells the client to start heartbeating early on, so the
56
+ # server can safely search for a session server to handle the connection without the connection being terminated.
57
+ # As a side-effect, large bots are less likely to disconnect because of very large READY parse times.
58
+ HELLO = 10
59
+
60
+ # **Received**: Returned after a heartbeat was sent to the server. This allows clients to identify and deal with
61
+ # zombie connections that don't dispatch any events anymore.
62
+ HEARTBEAT_ACK = 11
63
+ end
64
+
65
+ # @!visibility private
66
+ class Session
67
+ attr_reader :session_id
68
+ attr_accessor :sequence
69
+
70
+ def initialize(session_id)
71
+ @session_id = session_id
72
+ @sequence = 0
73
+ @suspended = false
74
+ @invalid = false
75
+ end
76
+
77
+ def suspend
78
+ @suspended = true
79
+ end
80
+
81
+ def resume
82
+ @suspended = false
83
+ end
84
+
85
+ def suspended?
86
+ @suspended
87
+ end
88
+
89
+ def invalidate
90
+ @invalid = true
91
+ end
92
+
93
+ def invalid?
94
+ @invalid
95
+ end
96
+
97
+ def should_resume?
98
+ @suspended && !@invalid
99
+ end
100
+ end
101
+
102
+ class Gateway
103
+ GATEWAY_VERSION = 6
104
+
105
+ LARGE_THRESHOLD = 100
106
+
107
+ attr_accessor :check_heartbeat_acks
108
+
109
+ def initialize(bot, token, shard_key = nil)
110
+ @bot, @token, @shard_key = bot, token, shard_key
111
+
112
+ @ws_success = false
113
+ @getc_mutex = Mutex.new
114
+
115
+ @check_heartbeat_acks = true
116
+ end
117
+
118
+ def run_async
119
+ @ws_thread = Thread.new do
120
+ Thread.current[:mij_discord] = 'websocket'
121
+
122
+ @reconnect_delay = 1.0
123
+
124
+ loop do
125
+ ws_connect
126
+
127
+ break unless @should_reconnect
128
+
129
+ if @instant_reconnect
130
+ @reconnect_delay = 1.0
131
+ @instant_reconnect = false
132
+ else
133
+ sleep(@reconnect_delay)
134
+ @reconnect_delay = [@reconnect_delay * 1.5, 120].min
135
+ end
136
+ end
137
+
138
+ MijDiscord::LOGGER.info('Gateway') { 'Websocket loop has been terminated' }
139
+ end
140
+
141
+ sleep(0.2) until @ws_success
142
+ MijDiscord::LOGGER.info('Gateway') { 'Connection established and confirmed' }
143
+ nil
144
+ end
145
+
146
+ def sync
147
+ @ws_thread&.join
148
+ nil
149
+ end
150
+
151
+ def kill
152
+ @ws_thread&.kill
153
+ nil
154
+ end
155
+
156
+ def open?
157
+ @handshake&.finished? && !@ws_closed
158
+ end
159
+
160
+ def stop(no_sync = false)
161
+ @should_reconnect = false
162
+ ws_close(no_sync)
163
+ nil
164
+ end
165
+
166
+ def heartbeat
167
+ if check_heartbeat_acks
168
+ unless @last_heartbeat_acked
169
+ MijDiscord::LOGGER.warn('Gateway') { 'Heartbeat not acknowledged, attempting to reconnect' }
170
+
171
+ @broken_pipe = true
172
+ reconnect(true)
173
+ return
174
+ end
175
+
176
+ @last_heartbeat_acked = false
177
+ end
178
+
179
+ send_heartbeat(@session&.sequence || 0)
180
+ end
181
+
182
+ def reconnect(try_resume = true)
183
+ @session&.suspend if try_resume
184
+
185
+ @instant_reconnect = true
186
+ @should_reconnect = true
187
+
188
+ ws_close(false)
189
+ nil
190
+ end
191
+
192
+ def send_heartbeat(sequence)
193
+ send_packet(Opcodes::HEARTBEAT, sequence)
194
+ end
195
+
196
+ def send_identify(token, properties, compress, large_threshold, shard_key)
197
+ data = {
198
+ token: token,
199
+ properties: properties,
200
+ compress: compress,
201
+ large_threshold: large_threshold,
202
+ }
203
+
204
+ data[:shard] = shard_key if shard_key
205
+
206
+ send_packet(Opcodes::IDENTIFY, data)
207
+ end
208
+
209
+ def send_status_update(status, since, game, afk)
210
+ data = {
211
+ status: status,
212
+ since: since,
213
+ game: game,
214
+ afk: afk,
215
+ }
216
+
217
+ send_packet(Opcodes::PRESENCE, data)
218
+ end
219
+
220
+ def send_voice_state_update(server_id, channel_id, self_mute, self_deaf)
221
+ data = {
222
+ guild_id: server_id,
223
+ channel_id: channel_id,
224
+ self_mute: self_mute,
225
+ self_deaf: self_deaf,
226
+ }
227
+
228
+ send_packet(Opcodes::VOICE_STATE, data)
229
+ end
230
+
231
+ def send_resume(token, session_id, sequence)
232
+ data = {
233
+ token: token,
234
+ session_id: session_id,
235
+ seq: sequence,
236
+ }
237
+
238
+ send_packet(Opcodes::RESUME, data)
239
+ end
240
+
241
+ def send_request_members(server_id, query, limit)
242
+ data = {
243
+ guild_id: server_id,
244
+ query: query,
245
+ limit: limit,
246
+ }
247
+
248
+ send_packet(Opcodes::REQUEST_MEMBERS, data)
249
+ end
250
+
251
+ def send_packet(opcode, packet)
252
+ data = {
253
+ op: opcode,
254
+ d: packet,
255
+ }
256
+
257
+ ws_send(data.to_json, :text)
258
+ nil
259
+ end
260
+
261
+ def send_raw(data, type = :text)
262
+ ws_send(data, type)
263
+ nil
264
+ end
265
+
266
+ def notify_ready
267
+ @ws_success = true
268
+ end
269
+
270
+ private
271
+
272
+ def send_identify_self
273
+ props = {
274
+ '$os': RUBY_PLATFORM,
275
+ '$browser': 'mij-discord',
276
+ '$device': 'mij-discord',
277
+ '$referrer': '',
278
+ '$referring_domain': '',
279
+ }
280
+
281
+ send_identify(@token, props, true, LARGE_THRESHOLD, @shard_key)
282
+ end
283
+
284
+ def send_resume_self
285
+ send_resume(@token, @session.session_id, @session.sequence)
286
+ end
287
+
288
+ def setup_heartbeat(interval)
289
+ @last_heartbeat_acked = true
290
+
291
+ return if @heartbeat_thread
292
+
293
+ @heartbeat_thread = Thread.new do
294
+ Thread.current[:mij_discord] = 'heartbeat'
295
+
296
+ loop do
297
+ begin
298
+ if @session&.suspended?
299
+ sleep(1.0)
300
+ else
301
+ sleep(interval)
302
+ @bot.handle_heartbeat
303
+ heartbeat
304
+ end
305
+ rescue => exc
306
+ MijDiscord::LOGGER.error('Gateway') { 'An error occurred during heartbeat' }
307
+ MijDiscord::LOGGER.error('Gateway') { exc }
308
+ end
309
+ end
310
+ end
311
+ end
312
+
313
+ def obtain_socket(uri)
314
+ secure = %w[https wss].include?(uri.scheme)
315
+ socket = TCPSocket.new(uri.host, uri.port || (secure ? 443 : 80))
316
+
317
+ if secure
318
+ ctx = OpenSSL::SSL::SSLContext.new
319
+ ctx.ssl_version = 'SSLv23'
320
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
321
+
322
+ cert_store = OpenSSL::X509::Store.new
323
+ cert_store.set_default_paths
324
+ ctx.cert_store = cert_store
325
+
326
+ socket = OpenSSL::SSL::SSLSocket.new(socket, ctx)
327
+ socket.connect
328
+ end
329
+
330
+ socket
331
+ end
332
+
333
+ def get_gateway_url
334
+ response = API.gateway(@token)
335
+ raw_url = JSON.parse(response)['url']
336
+ raw_url << '/' unless raw_url.end_with? '/'
337
+ "#{raw_url}?encoding=json&v=#{GATEWAY_VERSION}"
338
+ end
339
+
340
+ def ws_connect
341
+ url = get_gateway_url
342
+ gateway_uri = URI.parse(url)
343
+
344
+ @socket = obtain_socket(gateway_uri)
345
+ @handshake = WebSocket::Handshake::Client.new(url: url)
346
+ @handshake_done, @broken_pipe, @ws_closed = false, false, false
347
+
348
+ ws_mainloop
349
+ rescue => exc
350
+ MijDiscord::LOGGER.error('Gateway') { 'An error occurred during websocket connect' }
351
+ MijDiscord::LOGGER.error('Gateway') { exc }
352
+ end
353
+
354
+ def ws_mainloop
355
+ @bot.handle_dispatch(:CONNECT, nil)
356
+
357
+ @socket.write(@handshake.to_s)
358
+
359
+ frame = WebSocket::Frame::Incoming::Client.new
360
+
361
+ until @ws_closed
362
+ begin
363
+ unless @socket
364
+ ws_close(false)
365
+ MijDiscord::LOGGER.error('Gateway') { 'Socket object is nil in main websocket loop' }
366
+ end
367
+
368
+ recv_data = nil
369
+ @getc_mutex.synchronize { recv_data = @socket&.getc }
370
+
371
+ unless recv_data
372
+ sleep(1.0)
373
+ next
374
+ end
375
+
376
+ if @handshake_done
377
+ frame << recv_data
378
+
379
+ loop do
380
+ msg = frame.next
381
+ break unless msg
382
+
383
+ if msg.respond_to?(:code) && msg.code
384
+ MijDiscord::LOGGER.warn('Gateway') { 'Received websocket close frame' }
385
+ MijDiscord::LOGGER.warn('Gateway') { "(code: #{msg.code}, info: #{msg.data})" }
386
+
387
+ codes = [1000, 4004, 4010, 4011]
388
+ if codes.include?(msg.code)
389
+ ws_close(false)
390
+ else
391
+ MijDiscord::LOGGER.warn('Gateway') { 'Non-fatal code, attempting to reconnect' }
392
+ reconnect(true)
393
+ end
394
+
395
+ break
396
+ end
397
+
398
+ handle_message(msg.data)
399
+ end
400
+ else
401
+ @handshake << recv_data
402
+ @handshake_done = true if @handshake.finished?
403
+ end
404
+ rescue Errno::ECONNRESET
405
+ @broken_pipe = true
406
+ reconnect(true)
407
+ MijDiscord::LOGGER.warn('Gateway') { 'Connection reset by remote host, attempting to reconnect' }
408
+ rescue => exc
409
+ MijDiscord::LOGGER.error('Gateway') { 'An error occurred in main websocket loop' }
410
+ MijDiscord::LOGGER.error('Gateway') { exc }
411
+ end
412
+ end
413
+ end
414
+
415
+ def ws_send(data, type)
416
+ unless @handshake_done && !@ws_closed
417
+ raise StandardError, 'Tried to send something to the websocket while not being connected!'
418
+ end
419
+
420
+ frame = WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version)
421
+
422
+ begin
423
+ @socket.write frame.to_s
424
+ rescue => e
425
+ @broken_pipe = true
426
+ ws_close(false)
427
+ MijDiscord::LOGGER.error('Gateway') { 'An error occurred during websocket write' }
428
+ MijDiscord::LOGGER.error('Gateway') { e }
429
+ end
430
+ end
431
+
432
+ def ws_close(no_sync)
433
+ return if @ws_closed
434
+
435
+ @session&.suspend
436
+
437
+ ws_send(nil, :close) unless @broken_pipe
438
+
439
+ if no_sync
440
+ @ws_closed = true
441
+ else
442
+ @getc_mutex.synchronize { @ws_closed = true }
443
+ end
444
+
445
+ @socket&.close
446
+ @socket = nil
447
+
448
+ @bot.handle_dispatch(:DISCONNECT, nil)
449
+ end
450
+
451
+ def handle_message(msg)
452
+ msg = Zlib::Inflate.inflate(msg) if msg.byteslice(0) == 'x'
453
+
454
+ packet = JSON.parse(msg)
455
+ @session&.sequence = packet['s'] if packet['s']
456
+
457
+ case (opc = packet['op'].to_i)
458
+ when Opcodes::DISPATCH
459
+ handle_dispatch(packet)
460
+ when Opcodes::HELLO
461
+ handle_hello(packet)
462
+ when Opcodes::RECONNECT
463
+ reconnect
464
+ when Opcodes::INVALIDATE_SESSION
465
+ @session&.invalidate
466
+ send_identify_self
467
+ when Opcodes::HEARTBEAT_ACK
468
+ @last_heartbeat_acked = true if @check_heartbeat_acks
469
+ when Opcodes::HEARTBEAT
470
+ send_heartbeat(packet['s'])
471
+ else
472
+ MijDiscord::LOGGER.error('Gateway') { "Invalid opcode received: #{opc}" }
473
+ end
474
+ end
475
+
476
+ def handle_dispatch(packet)
477
+ data, type = packet['d'], packet['t'].to_sym
478
+
479
+ case type
480
+ when :READY
481
+ @session = Session.new(data['session_id'])
482
+
483
+ MijDiscord::LOGGER.info('Gateway') { "Received READY packet (user: #{data['user']['id']})" }
484
+ MijDiscord::LOGGER.info('Gateway') { "Using gateway protocol version #{data['v']}, requested #{GATEWAY_VERSION}" }
485
+ when :RESUMED
486
+ MijDiscord::LOGGER.info('Gateway') { 'Received session resume confirmation' }
487
+ return
488
+ end
489
+
490
+ @bot.handle_dispatch(type, data)
491
+ end
492
+
493
+ def handle_hello(packet)
494
+ interval = packet['d']['heartbeat_interval'].to_f / 1000.0
495
+ setup_heartbeat(interval)
496
+
497
+ if @session&.should_resume?
498
+ @session.resume
499
+ send_resume_self
500
+ else
501
+ send_identify_self
502
+ end
503
+ end
504
+ end
505
+ end