mij-discord 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 (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