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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/Rakefile +10 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/lib/mij-discord.rb +56 -0
- data/lib/mij-discord/bot.rb +579 -0
- data/lib/mij-discord/cache.rb +298 -0
- data/lib/mij-discord/core/api.rb +228 -0
- data/lib/mij-discord/core/api/channel.rb +416 -0
- data/lib/mij-discord/core/api/invite.rb +43 -0
- data/lib/mij-discord/core/api/server.rb +465 -0
- data/lib/mij-discord/core/api/user.rb +144 -0
- data/lib/mij-discord/core/errors.rb +106 -0
- data/lib/mij-discord/core/gateway.rb +505 -0
- data/lib/mij-discord/data.rb +65 -0
- data/lib/mij-discord/data/application.rb +38 -0
- data/lib/mij-discord/data/channel.rb +404 -0
- data/lib/mij-discord/data/embed.rb +115 -0
- data/lib/mij-discord/data/emoji.rb +62 -0
- data/lib/mij-discord/data/invite.rb +87 -0
- data/lib/mij-discord/data/member.rb +174 -0
- data/lib/mij-discord/data/message.rb +206 -0
- data/lib/mij-discord/data/permissions.rb +121 -0
- data/lib/mij-discord/data/role.rb +99 -0
- data/lib/mij-discord/data/server.rb +359 -0
- data/lib/mij-discord/data/user.rb +173 -0
- data/lib/mij-discord/data/voice.rb +68 -0
- data/lib/mij-discord/events.rb +133 -0
- data/lib/mij-discord/events/basic.rb +80 -0
- data/lib/mij-discord/events/channel.rb +50 -0
- data/lib/mij-discord/events/member.rb +66 -0
- data/lib/mij-discord/events/message.rb +150 -0
- data/lib/mij-discord/events/server.rb +102 -0
- data/lib/mij-discord/logger.rb +20 -0
- data/lib/mij-discord/version.rb +5 -0
- data/mij-discord.gemspec +31 -0
- 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
|