discordrb 2.1.3 → 3.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.
Potentially problematic release.
This version of discordrb might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +76 -0
- data/README.md +4 -2
- data/discordrb.gemspec +1 -1
- data/examples/commands.rb +2 -0
- data/examples/data/music.dca +0 -0
- data/examples/data/music.mp3 +0 -0
- data/examples/eval.rb +6 -3
- data/examples/ping.rb +14 -1
- data/examples/ping_with_respond_time.rb +6 -4
- data/examples/pm_send.rb +3 -0
- data/examples/shutdown.rb +7 -2
- data/examples/voice_send.rb +51 -0
- data/lib/discordrb/api.rb +66 -460
- data/lib/discordrb/api/channel.rb +306 -0
- data/lib/discordrb/api/invite.rb +41 -0
- data/lib/discordrb/api/server.rb +357 -0
- data/lib/discordrb/api/user.rb +134 -0
- data/lib/discordrb/bot.rb +266 -576
- data/lib/discordrb/cache.rb +27 -28
- data/lib/discordrb/commands/command_bot.rb +44 -15
- data/lib/discordrb/commands/container.rb +3 -2
- data/lib/discordrb/commands/parser.rb +14 -6
- data/lib/discordrb/container.rb +30 -3
- data/lib/discordrb/data.rb +823 -189
- data/lib/discordrb/errors.rb +145 -0
- data/lib/discordrb/events/channels.rb +63 -3
- data/lib/discordrb/events/members.rb +1 -2
- data/lib/discordrb/events/message.rb +96 -17
- data/lib/discordrb/events/presence.rb +15 -0
- data/lib/discordrb/events/typing.rb +7 -1
- data/lib/discordrb/gateway.rb +724 -0
- data/lib/discordrb/light/light_bot.rb +6 -4
- data/lib/discordrb/logger.rb +26 -9
- data/lib/discordrb/permissions.rb +6 -3
- data/lib/discordrb/version.rb +1 -1
- data/lib/discordrb/voice/voice_bot.rb +29 -6
- metadata +12 -5
- data/lib/discordrb/token_cache.rb +0 -181
@@ -62,12 +62,20 @@ module Discordrb::Events
|
|
62
62
|
# @return [String] the new game the user is playing.
|
63
63
|
attr_reader :game
|
64
64
|
|
65
|
+
# @return [String] the URL to the stream
|
66
|
+
attr_reader :url
|
67
|
+
|
68
|
+
# @return [Integer] the type of play. 0 = game, 1 = Twitch
|
69
|
+
attr_reader :type
|
70
|
+
|
65
71
|
def initialize(data, bot)
|
66
72
|
@bot = bot
|
67
73
|
|
68
74
|
@user = bot.user(data['user']['id'].to_i)
|
69
75
|
@game = data['game'] ? data['game']['name'] : nil
|
70
76
|
@server = bot.server(data['guild_id'].to_i)
|
77
|
+
@url = data['game'] ? data['game']['url'] : nil
|
78
|
+
@type = data['game'] ? data['game']['type'].to_i : nil
|
71
79
|
end
|
72
80
|
end
|
73
81
|
|
@@ -93,6 +101,13 @@ module Discordrb::Events
|
|
93
101
|
else
|
94
102
|
e
|
95
103
|
end
|
104
|
+
end,
|
105
|
+
matches_all(@attributes[:type], event.type) do |a, e|
|
106
|
+
a == if a.is_a? Integer
|
107
|
+
e.type
|
108
|
+
else
|
109
|
+
e
|
110
|
+
end
|
96
111
|
end
|
97
112
|
].reduce(true, &:&)
|
98
113
|
end
|
@@ -23,7 +23,13 @@ module Discordrb::Events
|
|
23
23
|
@channel_id = data['channel_id'].to_i
|
24
24
|
@channel = bot.channel(@channel_id)
|
25
25
|
|
26
|
-
@user = channel.
|
26
|
+
@user = if channel.pm?
|
27
|
+
channel.recipient
|
28
|
+
elsif channel.group?
|
29
|
+
bot.user(@user_id)
|
30
|
+
else
|
31
|
+
bot.member(@channel.server.id, @user_id)
|
32
|
+
end
|
27
33
|
|
28
34
|
@timestamp = Time.at(data['timestamp'].to_i)
|
29
35
|
end
|
@@ -0,0 +1,724 @@
|
|
1
|
+
# This file uses code from Websocket::Client::Simple, licensed under the following license:
|
2
|
+
#
|
3
|
+
# Copyright (c) 2013-2014 Sho Hashimoto
|
4
|
+
#
|
5
|
+
# MIT License
|
6
|
+
#
|
7
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
8
|
+
# a copy of this software and associated documentation files (the
|
9
|
+
# "Software"), to deal in the Software without restriction, including
|
10
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
11
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
12
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
13
|
+
# the following conditions:
|
14
|
+
#
|
15
|
+
# The above copyright notice and this permission notice shall be
|
16
|
+
# included in all copies or substantial portions of the Software.
|
17
|
+
#
|
18
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
19
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
20
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
21
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
22
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
23
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
24
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
25
|
+
|
26
|
+
require 'thread'
|
27
|
+
|
28
|
+
module Discordrb
|
29
|
+
# Gateway packet opcodes
|
30
|
+
module Opcodes
|
31
|
+
# **Received** when Discord dispatches an event to the gateway (like MESSAGE_CREATE, PRESENCE_UPDATE or whatever).
|
32
|
+
# The vast majority of received packets will have this opcode.
|
33
|
+
DISPATCH = 0
|
34
|
+
|
35
|
+
# **Two-way**: The client has to send a packet with this opcode every ~40 seconds (actual interval specified in
|
36
|
+
# READY or RESUMED) and the current sequence number, otherwise it will be disconnected from the gateway. In certain
|
37
|
+
# cases Discord may also send one, specifically if two clients are connected at once.
|
38
|
+
HEARTBEAT = 1
|
39
|
+
|
40
|
+
# **Sent**: This is one of the two possible ways to initiate a session after connecting to the gateway. It
|
41
|
+
# should contain the authentication token along with other stuff the server has to know right from the start, such
|
42
|
+
# as large_threshold and, for older gateway versions, the desired version.
|
43
|
+
IDENTIFY = 2
|
44
|
+
|
45
|
+
# **Sent**: Packets with this opcode are used to change the user's status and played game. (Sending this is never
|
46
|
+
# necessary for a gateway client to behave correctly)
|
47
|
+
PRESENCE = 3
|
48
|
+
|
49
|
+
# **Sent**: Packets with this opcode are used to change a user's voice state (mute/deaf/unmute/undeaf/etc.). It is
|
50
|
+
# also used to connect to a voice server in the first place. (Sending this is never necessary for a gateway client
|
51
|
+
# to behave correctly)
|
52
|
+
VOICE_STATE = 4
|
53
|
+
|
54
|
+
# **Sent**: This opcode is used to ping a voice server, whatever that means. The functionality of this opcode isn't
|
55
|
+
# known well but non-user clients should never send it.
|
56
|
+
VOICE_PING = 5
|
57
|
+
|
58
|
+
# **Sent**: This is the other of two possible ways to initiate a gateway session (other than {IDENTIFY}). Rather
|
59
|
+
# than starting an entirely new session, it resumes an existing session by replaying all events from a given
|
60
|
+
# sequence number. It should be used to recover from a connection error or anything like that when the session is
|
61
|
+
# still valid - sending this with an invalid session will cause an error to occur.
|
62
|
+
RESUME = 6
|
63
|
+
|
64
|
+
# **Received**: Discord sends this opcode to indicate that the client should reconnect to a different gateway
|
65
|
+
# server because the old one is currently being decommissioned. Counterintuitively, this opcode also invalidates the
|
66
|
+
# session - the client has to create an entirely new session with the new gateway instead of resuming the old one.
|
67
|
+
RECONNECT = 7
|
68
|
+
|
69
|
+
# **Sent**: This opcode identifies packets used to retrieve a list of members from a particular server. There is
|
70
|
+
# also a REST endpoint available for this, but it is inconvenient to use because the client has to implement
|
71
|
+
# pagination itself, whereas sending this opcode lets Discord handle the pagination and the client can just add
|
72
|
+
# members when it receives them. (Sending this is never necessary for a gateway client to behave correctly)
|
73
|
+
REQUEST_MEMBERS = 8
|
74
|
+
|
75
|
+
# **Received**: The functionality of this opcode is less known than the others but it appears to specifically
|
76
|
+
# tell the client to invalidate its local session and continue by {IDENTIFY}ing.
|
77
|
+
INVALIDATE_SESSION = 9
|
78
|
+
|
79
|
+
# **Received**: Sent immediately for any opened connection; tells the client to start heartbeating early on, so the
|
80
|
+
# server can safely search for a session server to handle the connection without the connection being terminated.
|
81
|
+
# As a side-effect, large bots are less likely to disconnect because of very large READY parse times.
|
82
|
+
HELLO = 10
|
83
|
+
|
84
|
+
# **Received**: Returned after a heartbeat was sent to the server. This allows clients to identify and deal with
|
85
|
+
# zombie connections that don't dispatch any events anymore.
|
86
|
+
HEARTBEAT_ACK = 11
|
87
|
+
end
|
88
|
+
|
89
|
+
# This class stores the data of an active gateway session. Note that this is different from a websocket connection -
|
90
|
+
# there may be multiple sessions per connection or one session may persist over multiple connections.
|
91
|
+
class Session
|
92
|
+
attr_reader :session_id
|
93
|
+
attr_accessor :sequence
|
94
|
+
|
95
|
+
def initialize(session_id)
|
96
|
+
@id = session_id
|
97
|
+
@sequence = 0
|
98
|
+
@suspended = false
|
99
|
+
@invalid = false
|
100
|
+
end
|
101
|
+
|
102
|
+
def suspend
|
103
|
+
@suspended = true
|
104
|
+
end
|
105
|
+
|
106
|
+
def suspended?
|
107
|
+
@suspended
|
108
|
+
end
|
109
|
+
|
110
|
+
def invalidate
|
111
|
+
@invalid = true
|
112
|
+
end
|
113
|
+
|
114
|
+
def invalid?
|
115
|
+
@invalid
|
116
|
+
end
|
117
|
+
|
118
|
+
def should_resume?
|
119
|
+
suspended? && !invalid?
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Client for the Discord gateway protocol
|
124
|
+
class Gateway
|
125
|
+
# How many members there need to be in a server for it to count as "large"
|
126
|
+
LARGE_THRESHOLD = 100
|
127
|
+
|
128
|
+
# The version of the gateway that's supposed to be used.
|
129
|
+
GATEWAY_VERSION = 6
|
130
|
+
|
131
|
+
def initialize(bot, token)
|
132
|
+
@token = token
|
133
|
+
@bot = bot
|
134
|
+
|
135
|
+
@getc_mutex = Mutex.new
|
136
|
+
|
137
|
+
# Whether the connection to the gateway has succeeded yet
|
138
|
+
@ws_success = false
|
139
|
+
end
|
140
|
+
|
141
|
+
# Connect to the gateway server in a separate thread
|
142
|
+
def run_async
|
143
|
+
@ws_thread = Thread.new do
|
144
|
+
Thread.current[:discordrb_name] = 'websocket'
|
145
|
+
connect_loop
|
146
|
+
LOGGER.warn('The WS loop exited! Not sure if this is a good thing')
|
147
|
+
end
|
148
|
+
|
149
|
+
LOGGER.debug('WS thread created! Now waiting for confirmation that everything worked')
|
150
|
+
sleep(0.5) until @ws_success
|
151
|
+
LOGGER.debug('Confirmation received! Exiting run.')
|
152
|
+
end
|
153
|
+
|
154
|
+
# Prevents all further execution until the websocket thread stops (e. g. through a closed connection).
|
155
|
+
def sync
|
156
|
+
@ws_thread.join
|
157
|
+
end
|
158
|
+
|
159
|
+
# Whether the WebSocket connection to the gateway is currently open
|
160
|
+
def open?
|
161
|
+
@handshake && @handshake.finished? && !@closed
|
162
|
+
end
|
163
|
+
|
164
|
+
# Stops the bot gracefully, disconnecting the websocket without immediately killing the thread. This means that
|
165
|
+
# Discord is immediately aware of the closed connection and makes the bot appear offline instantly.
|
166
|
+
#
|
167
|
+
# If this method doesn't work or you're looking for something more drastic, use {#kill} instead.
|
168
|
+
def stop(no_sync = false)
|
169
|
+
@should_reconnect = false
|
170
|
+
close(no_sync)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Kills the websocket thread, stopping all connections to Discord.
|
174
|
+
def kill
|
175
|
+
@ws_thread.kill
|
176
|
+
end
|
177
|
+
|
178
|
+
# Notifies the {#run_async} method that everything is ready and the caller can now continue (i.e. with syncing,
|
179
|
+
# or with doing processing and then syncing)
|
180
|
+
def notify_ready
|
181
|
+
@ws_success = true
|
182
|
+
end
|
183
|
+
|
184
|
+
# Injects a reconnect event (op 7) into the event processor, causing Discord to reconnect to the given gateway URL.
|
185
|
+
# If the URL is set to nil, it will reconnect and get an entirely new gateway URL. This method has not much use
|
186
|
+
# outside of testing and implementing highly custom reconnect logic.
|
187
|
+
# @param url [String, nil] the URL to connect to or nil if one should be obtained from Discord.
|
188
|
+
def inject_reconnect(url = nil)
|
189
|
+
# When no URL is specified, the data should be nil, as is the case with Discord-sent packets.
|
190
|
+
data = url ? { url: url } : nil
|
191
|
+
|
192
|
+
handle_message({
|
193
|
+
op: Opcodes::RECONNECT,
|
194
|
+
d: data
|
195
|
+
}.to_json)
|
196
|
+
end
|
197
|
+
|
198
|
+
# Injects a resume packet (op 6) into the gateway. If this is done with a running connection, it will cause an
|
199
|
+
# error. It has no use outside of testing stuff that I know of, but if you want to use it anyway for some reason,
|
200
|
+
# here it is.
|
201
|
+
# @param seq [Integer, nil] The sequence ID to inject, or nil if the currently tracked one should be used.
|
202
|
+
def inject_resume(seq)
|
203
|
+
send_resume(raw_token, @session_id, seq || @sequence)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Injects a terminal gateway error into the handler. Useful for testing the reconnect logic.
|
207
|
+
# @param e [Exception] The exception object to inject.
|
208
|
+
def inject_error(e)
|
209
|
+
handle_internal_close(e)
|
210
|
+
end
|
211
|
+
|
212
|
+
# Sends a heartbeat with the last received packet's seq (to acknowledge that we have received it and all packets
|
213
|
+
# before it), or if none have been received yet, with 0.
|
214
|
+
# @see #send_heartbeat
|
215
|
+
def heartbeat
|
216
|
+
send_heartbeat(@session ? @session.sequence : 0)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Sends a heartbeat packet (op 1). This tells Discord that the current connection is still active and that the last
|
220
|
+
# packets until the given sequence have been processed (in case of a resume).
|
221
|
+
# @param sequence [Integer] The sequence number for which to send a heartbeat.
|
222
|
+
def send_heartbeat(sequence)
|
223
|
+
send_packet(Opcodes::HEARTBEAT, sequence)
|
224
|
+
end
|
225
|
+
|
226
|
+
# Identifies to Discord with the default parameters.
|
227
|
+
# @see #send_identify
|
228
|
+
def identify
|
229
|
+
send_identify(@token, {
|
230
|
+
:'$os' => RUBY_PLATFORM,
|
231
|
+
:'$browser' => 'discordrb',
|
232
|
+
:'$device' => 'discordrb',
|
233
|
+
:'$referrer' => '',
|
234
|
+
:'$referring_domain' => ''
|
235
|
+
}, true, 100)
|
236
|
+
end
|
237
|
+
|
238
|
+
# Sends an identify packet (op 2). This starts a new session on the current connection and tells Discord who we are.
|
239
|
+
# This can only be done once a connection.
|
240
|
+
# @param token [String] The token with which to authorise the session. If it belongs to a bot account, it must be
|
241
|
+
# prefixed with "Bot ".
|
242
|
+
# @param properties [Hash<Symbol => String>] A list of properties for Discord to use in analytics. The following
|
243
|
+
# keys are recognised:
|
244
|
+
#
|
245
|
+
# - "$os" (recommended value: the operating system the bot is running on)
|
246
|
+
# - "$browser" (recommended value: library name)
|
247
|
+
# - "$device" (recommended value: library name)
|
248
|
+
# - "$referrer" (recommended value: empty)
|
249
|
+
# - "$referring_domain" (recommended value: empty)
|
250
|
+
#
|
251
|
+
# @param compress [true, false] Whether certain large packets should be compressed using zlib.
|
252
|
+
# @param large_threshold [Integer] The member threshold after which a server counts as large and will have to have
|
253
|
+
# its member list chunked.
|
254
|
+
def send_identify(token, properties, compress, large_threshold)
|
255
|
+
data = {
|
256
|
+
# Don't send a v anymore as it's entirely determined by the URL now
|
257
|
+
token: token,
|
258
|
+
properties: properties,
|
259
|
+
compress: compress,
|
260
|
+
large_threshold: large_threshold
|
261
|
+
}
|
262
|
+
|
263
|
+
send_packet(Opcodes::IDENTIFY, data)
|
264
|
+
end
|
265
|
+
|
266
|
+
# Sends a status update packet (op 3). This sets the bot user's status (online/idle/...) and game playing/streaming.
|
267
|
+
# @param status [String] The status that should be set (`online`, `idle`, `dnd`, `invisible`).
|
268
|
+
# @param since [Integer] The unix timestamp in milliseconds when the status was set. Should only be provided when
|
269
|
+
# `afk` is true.
|
270
|
+
# @param game [Hash<Symbol => Object>, nil] `nil` if no game should be played, or a hash of `:game => "name"` if a
|
271
|
+
# game should be played. The hash can also contain additional attributes for streaming statuses.
|
272
|
+
# @param afk [true, false] Whether the status was set due to inactivity on the user's part.
|
273
|
+
def send_status_update(status, since, game, afk)
|
274
|
+
data = {
|
275
|
+
status: status,
|
276
|
+
since: since,
|
277
|
+
game: game,
|
278
|
+
afk: afk
|
279
|
+
}
|
280
|
+
|
281
|
+
send_packet(Opcodes::PRESENCE, data)
|
282
|
+
end
|
283
|
+
|
284
|
+
# Sends a voice state update packet (op 4). This packet can connect a user to a voice channel, update self mute/deaf
|
285
|
+
# status in an existing voice connection, move the user to a new voice channel on the same server or disconnect an
|
286
|
+
# existing voice connection.
|
287
|
+
# @param server_id [Integer] The ID of the server on which this action should occur.
|
288
|
+
# @param channel_id [Integer, nil] The channel ID to connect/move to, or `nil` to disconnect.
|
289
|
+
# @param self_mute [true, false] Whether the user should itself be muted to everyone else.
|
290
|
+
# @param self_deaf [true, false] Whether the user should be deaf towards other users.
|
291
|
+
def send_voice_state_update(server_id, channel_id, self_mute, self_deaf)
|
292
|
+
data = {
|
293
|
+
guild_id: server_id,
|
294
|
+
channel_id: channel_id,
|
295
|
+
self_mute: self_mute,
|
296
|
+
self_deaf: self_deaf
|
297
|
+
}
|
298
|
+
|
299
|
+
send_packet(Opcodes::VOICE_STATE, data)
|
300
|
+
end
|
301
|
+
|
302
|
+
# Resumes the session from the last recorded point.
|
303
|
+
# @see #send_resume
|
304
|
+
def resume
|
305
|
+
send_resume(@token, @session.session_id, @session.sequence)
|
306
|
+
end
|
307
|
+
|
308
|
+
# Sends a resume packet (op 6). This replays all events from a previous point specified by its packet sequence. This
|
309
|
+
# will not work if the packet to resume from has already been acknowledged using a heartbeat, or if the session ID
|
310
|
+
# belongs to a now invalid session.
|
311
|
+
#
|
312
|
+
# If this packet is sent at the beginning of a connection, it will act similarly to an {#identify} in that it
|
313
|
+
# creates a session on the current connection. Unlike identify however, this packet can also be sent in an existing
|
314
|
+
# session and will just replay some of the events.
|
315
|
+
# @param token [String] The token that was used to identify the session to resume.
|
316
|
+
# @param session_id [String] The session ID of the session to resume.
|
317
|
+
# @param seq [Integer] The packet sequence of the packet after which the events should be replayed.
|
318
|
+
def send_resume(token, session_id, seq)
|
319
|
+
data = {
|
320
|
+
token: token,
|
321
|
+
session_id: session_id,
|
322
|
+
seq: seq
|
323
|
+
}
|
324
|
+
|
325
|
+
send_packet(Opcodes::RESUME, data)
|
326
|
+
end
|
327
|
+
|
328
|
+
# Sends a request members packet (op 8). This will order Discord to gradually sent all requested members as dispatch
|
329
|
+
# events with type `GUILD_MEMBERS_CHUNK`. It is necessary to use this method in order to get all members of a large
|
330
|
+
# server (see `large_threshold` in {#send_identify}), however it can also be used for other purposes.
|
331
|
+
# @param server_id [Integer] The ID of the server whose members to query.
|
332
|
+
# @param query [String] If this string is not empty, only members whose username starts with this string will be
|
333
|
+
# returned.
|
334
|
+
# @param limit [Integer] How many members to send at maximum, or `0` to send all members.
|
335
|
+
def send_request_members(server_id, query, limit)
|
336
|
+
data = {
|
337
|
+
guild_id: server_id,
|
338
|
+
query: query,
|
339
|
+
limit: limit
|
340
|
+
}
|
341
|
+
|
342
|
+
send_packet(Opcodes::REQUEST_MEMBERS, data)
|
343
|
+
end
|
344
|
+
|
345
|
+
private
|
346
|
+
|
347
|
+
def setup_heartbeats(interval)
|
348
|
+
# We don't want to have redundant heartbeat threads, so if one already exists, don't start a new one
|
349
|
+
return if @heartbeat_thread
|
350
|
+
|
351
|
+
@heartbeat_interval = interval
|
352
|
+
@heartbeat_thread = Thread.new do
|
353
|
+
Thread.current[:discordrb_name] = 'heartbeat'
|
354
|
+
loop do
|
355
|
+
begin
|
356
|
+
# Send a heartbeat if heartbeats are active and either no session exists yet, or an existing session is
|
357
|
+
# suspended (e.g. after op7)
|
358
|
+
if (@session && !@session.suspended?) || !@session
|
359
|
+
sleep @heartbeat_interval
|
360
|
+
@bot.raise_heartbeat_event
|
361
|
+
heartbeat
|
362
|
+
else
|
363
|
+
sleep 1
|
364
|
+
end
|
365
|
+
rescue => e
|
366
|
+
LOGGER.error('An error occurred while heartbeating!')
|
367
|
+
LOGGER.log_exception(e)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
def connect_loop
|
374
|
+
# Initialize falloff so we wait for more time before reconnecting each time
|
375
|
+
@falloff = 1.0
|
376
|
+
|
377
|
+
@should_reconnect = true
|
378
|
+
loop do
|
379
|
+
connect
|
380
|
+
|
381
|
+
break unless @should_reconnect
|
382
|
+
|
383
|
+
if @instant_reconnect
|
384
|
+
# We got an op 7! Don't wait before reconnecting
|
385
|
+
LOGGER.info('Got an op 7, reconnecting right away')
|
386
|
+
@instant_reconnect = false
|
387
|
+
else
|
388
|
+
wait_for_reconnect
|
389
|
+
end
|
390
|
+
|
391
|
+
# Restart the loop, i. e. reconnect
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
# Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
|
396
|
+
# unexpected way
|
397
|
+
def wait_for_reconnect
|
398
|
+
# We disconnected in an unexpected way! Wait before reconnecting so we don't spam Discord's servers.
|
399
|
+
LOGGER.debug("Attempting to reconnect in #{@falloff} seconds.")
|
400
|
+
sleep @falloff
|
401
|
+
|
402
|
+
# Calculate new falloff
|
403
|
+
@falloff *= 1.5
|
404
|
+
@falloff = 115 + (rand * 10) if @falloff > 120 # Cap the falloff at 120 seconds and then add some random jitter
|
405
|
+
end
|
406
|
+
|
407
|
+
# Create and connect a socket using a URI
|
408
|
+
def obtain_socket(uri)
|
409
|
+
socket = TCPSocket.new(uri.host, uri.port || socket_port(uri))
|
410
|
+
|
411
|
+
if secure_uri?(uri)
|
412
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
413
|
+
ctx.ssl_version = 'SSLv23'
|
414
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # use VERIFY_PEER for verification
|
415
|
+
|
416
|
+
cert_store = OpenSSL::X509::Store.new
|
417
|
+
cert_store.set_default_paths
|
418
|
+
ctx.cert_store = cert_store
|
419
|
+
|
420
|
+
socket = ::OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
421
|
+
socket.connect
|
422
|
+
end
|
423
|
+
|
424
|
+
socket
|
425
|
+
end
|
426
|
+
|
427
|
+
# Whether the URI is secure (connection should be encrypted)
|
428
|
+
def secure_uri?(uri)
|
429
|
+
%w(https wss).include? uri.scheme
|
430
|
+
end
|
431
|
+
|
432
|
+
# The port we should connect to, if the URI doesn't have one set.
|
433
|
+
def socket_port(uri)
|
434
|
+
secure_uri?(uri) ? 443 : 80
|
435
|
+
end
|
436
|
+
|
437
|
+
def find_gateway
|
438
|
+
response = API.gateway(@token)
|
439
|
+
JSON.parse(response)['url']
|
440
|
+
end
|
441
|
+
|
442
|
+
def process_gateway
|
443
|
+
raw_url = find_gateway
|
444
|
+
|
445
|
+
# Append a slash in case it's not there (I'm not sure how well WSCS handles it otherwise)
|
446
|
+
raw_url += '/' unless raw_url.end_with? '/'
|
447
|
+
|
448
|
+
# Add the parameters we want
|
449
|
+
raw_url + "?encoding=json&v=#{GATEWAY_VERSION}"
|
450
|
+
end
|
451
|
+
|
452
|
+
def connect
|
453
|
+
LOGGER.debug('Connecting')
|
454
|
+
|
455
|
+
# Get the URI we should connect to
|
456
|
+
url = process_gateway
|
457
|
+
LOGGER.debug("Gateway URL: #{url}")
|
458
|
+
|
459
|
+
# Parse it
|
460
|
+
gateway_uri = URI.parse(url)
|
461
|
+
|
462
|
+
# Connect to the obtained URI with a socket
|
463
|
+
@socket = obtain_socket(gateway_uri)
|
464
|
+
LOGGER.debug('Obtained socket')
|
465
|
+
|
466
|
+
# Initialise some properties
|
467
|
+
@handshake = ::WebSocket::Handshake::Client.new(url: url) # Represents the handshake between us and the server
|
468
|
+
@handshaked = false # Whether the handshake has finished yet
|
469
|
+
@pipe_broken = false # Whether we've received an EPIPE at any time
|
470
|
+
@closed = false # Whether the websocket is currently closed
|
471
|
+
|
472
|
+
# We're done! Delegate to the websocket loop
|
473
|
+
websocket_loop
|
474
|
+
rescue => e
|
475
|
+
LOGGER.error('An error occurred while connecting to the websocket!')
|
476
|
+
LOGGER.log_exception(e)
|
477
|
+
end
|
478
|
+
|
479
|
+
def websocket_loop
|
480
|
+
# Send the handshake data that we have so far
|
481
|
+
@socket.write(@handshake.to_s)
|
482
|
+
|
483
|
+
# Create a frame to handle received data
|
484
|
+
frame = ::WebSocket::Frame::Incoming::Client.new
|
485
|
+
|
486
|
+
until @closed
|
487
|
+
begin
|
488
|
+
recv_data = nil
|
489
|
+
|
490
|
+
# Get some data from the socket, synchronised so the socket can't be closed during this
|
491
|
+
@getc_mutex.synchronize { recv_data = @socket.getc }
|
492
|
+
|
493
|
+
# Check if we actually got data
|
494
|
+
unless recv_data
|
495
|
+
# If we didn't, wait
|
496
|
+
sleep 1
|
497
|
+
next
|
498
|
+
end
|
499
|
+
|
500
|
+
# Check whether the handshake has finished yet
|
501
|
+
if @handshaked
|
502
|
+
# If it hasn't, add the received data to the current frame
|
503
|
+
frame << recv_data
|
504
|
+
|
505
|
+
# Try to parse a message from the frame
|
506
|
+
msg = frame.next
|
507
|
+
while msg
|
508
|
+
# Check whether the message is a close frame, and if it is, handle accordingly
|
509
|
+
if msg.respond_to?(:code) && msg.code
|
510
|
+
handle_internal_close(msg)
|
511
|
+
break
|
512
|
+
end
|
513
|
+
|
514
|
+
# If there is one, handle it and try again
|
515
|
+
handle_message(msg.data)
|
516
|
+
msg = frame.next
|
517
|
+
end
|
518
|
+
else
|
519
|
+
# If the handshake hasn't finished, handle it
|
520
|
+
handle_handshake_data(recv_data)
|
521
|
+
end
|
522
|
+
rescue => e
|
523
|
+
handle_error(e)
|
524
|
+
end
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
def handle_handshake_data(recv_data)
|
529
|
+
@handshake << recv_data
|
530
|
+
return unless @handshake.finished?
|
531
|
+
|
532
|
+
@handshaked = true
|
533
|
+
handle_open
|
534
|
+
end
|
535
|
+
|
536
|
+
def handle_open
|
537
|
+
end
|
538
|
+
|
539
|
+
def handle_error(e)
|
540
|
+
LOGGER.error('An error occurred in the main websocket loop!')
|
541
|
+
LOGGER.log_exception(e)
|
542
|
+
end
|
543
|
+
|
544
|
+
def handle_message(msg)
|
545
|
+
if msg.byteslice(0) == 'x'
|
546
|
+
# The message is compressed, inflate it
|
547
|
+
msg = Zlib::Inflate.inflate(msg)
|
548
|
+
end
|
549
|
+
|
550
|
+
# Parse packet
|
551
|
+
packet = JSON.parse(msg)
|
552
|
+
op = packet['op'].to_i
|
553
|
+
|
554
|
+
LOGGER.in(packet)
|
555
|
+
|
556
|
+
# If the packet has a sequence defined (all dispatch packets have one), make sure to update that in the
|
557
|
+
# session so it will be acknowledged next heartbeat.
|
558
|
+
# Only do this, of course, if a session has been created already; for a READY dispatch (which has s=0 set but is
|
559
|
+
# the packet that starts the session in the first place) we need not do any handling since initialising the
|
560
|
+
# session will set it to 0 by default.
|
561
|
+
@session.sequence = packet['s'] if packet['s'] && @session
|
562
|
+
|
563
|
+
case op
|
564
|
+
when Opcodes::DISPATCH
|
565
|
+
handle_dispatch(packet)
|
566
|
+
when Opcodes::HELLO
|
567
|
+
handle_hello(packet)
|
568
|
+
when Opcodes::RECONNECT
|
569
|
+
handle_reconnect
|
570
|
+
when Opcodes::INVALIDATE_SESSION
|
571
|
+
handle_invalidate_session
|
572
|
+
when Opcodes::HEARTBEAT_ACK
|
573
|
+
handle_heartbeat_ack(packet)
|
574
|
+
else
|
575
|
+
LOGGER.warn("Received invalid opcode #{op} - please report with this information: #{msg}")
|
576
|
+
end
|
577
|
+
end
|
578
|
+
|
579
|
+
# Op 0
|
580
|
+
def handle_dispatch(packet)
|
581
|
+
data = packet['d']
|
582
|
+
type = packet['t'].intern
|
583
|
+
|
584
|
+
case type
|
585
|
+
when :READY
|
586
|
+
LOGGER.info("Discord using gateway protocol version: #{data['v']}, requested: #{GATEWAY_VERSION}")
|
587
|
+
|
588
|
+
@session = Session.new(data['session_id'])
|
589
|
+
@session.sequence = 0
|
590
|
+
when :RESUMED
|
591
|
+
# The RESUMED event is received after a successful op 6 (resume). It does nothing except tell the bot the
|
592
|
+
# connection is initiated (like READY would). Starting with v5, it doesn't set a new heartbeat interval anymore
|
593
|
+
# since that is handled by op 10 (HELLO).
|
594
|
+
LOGGER.debug('Connection resumed')
|
595
|
+
return
|
596
|
+
end
|
597
|
+
|
598
|
+
@bot.dispatch(type, data)
|
599
|
+
end
|
600
|
+
|
601
|
+
# Op 7
|
602
|
+
def handle_reconnect
|
603
|
+
@instant_reconnect = true
|
604
|
+
close
|
605
|
+
|
606
|
+
# Suspend session so we resume afterwards
|
607
|
+
@session.suspend
|
608
|
+
end
|
609
|
+
|
610
|
+
# Op 9
|
611
|
+
def handle_invalidate_session
|
612
|
+
LOGGER.debug('Received op 9, invalidating session and reidentifying.')
|
613
|
+
|
614
|
+
if @session
|
615
|
+
@session.invalidate
|
616
|
+
else
|
617
|
+
LOGGER.warn('Received op 9 without a running session! Not invalidating, we *should* be fine though.')
|
618
|
+
end
|
619
|
+
|
620
|
+
identify
|
621
|
+
end
|
622
|
+
|
623
|
+
# Op 10
|
624
|
+
def handle_hello(packet)
|
625
|
+
LOGGER.debug('Hello!')
|
626
|
+
|
627
|
+
# The heartbeat interval is given in ms, so divide it by 1000 to get seconds
|
628
|
+
interval = packet['d']['heartbeat_interval'].to_f / 1000.0
|
629
|
+
setup_heartbeats(interval)
|
630
|
+
|
631
|
+
LOGGER.debug("Trace: #{packet['d']['_trace']}")
|
632
|
+
|
633
|
+
if @session && @session.should_resume?
|
634
|
+
resume
|
635
|
+
else
|
636
|
+
identify
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
# Op 11
|
641
|
+
def handle_heartbeat_ack(packet)
|
642
|
+
LOGGER.debug("Received heartbeat ack for packet: #{packet.inspect}")
|
643
|
+
end
|
644
|
+
|
645
|
+
# Called when the websocket has been disconnected in some way - say due to a pipe error while sending
|
646
|
+
def handle_internal_close(e)
|
647
|
+
close
|
648
|
+
handle_close(e)
|
649
|
+
end
|
650
|
+
|
651
|
+
def handle_close(e)
|
652
|
+
if e.respond_to? :code
|
653
|
+
# It is a proper close frame we're dealing with, print reason and message to console
|
654
|
+
LOGGER.error('Websocket close frame received!')
|
655
|
+
LOGGER.error("Code: #{e.code}")
|
656
|
+
LOGGER.error("Message: #{e.data}")
|
657
|
+
elsif e.is_a? Exception
|
658
|
+
# Log the exception
|
659
|
+
LOGGER.error('The websocket connection has closed due to an error!')
|
660
|
+
LOGGER.log_exception(e)
|
661
|
+
else
|
662
|
+
LOGGER.error("The websocket connection has closed: #{e.inspect}")
|
663
|
+
end
|
664
|
+
end
|
665
|
+
|
666
|
+
def send_packet(op, packet)
|
667
|
+
data = {
|
668
|
+
op: op,
|
669
|
+
d: packet
|
670
|
+
}
|
671
|
+
|
672
|
+
send(data.to_json)
|
673
|
+
end
|
674
|
+
|
675
|
+
def send(data, type = :text)
|
676
|
+
LOGGER.out(data)
|
677
|
+
|
678
|
+
unless @handshaked && !@closed
|
679
|
+
# If we're not handshaked or closed, it means there's no connection to send anything to
|
680
|
+
raise 'Tried to send something to the websocket while not being connected!'
|
681
|
+
end
|
682
|
+
|
683
|
+
# Create the frame we're going to send
|
684
|
+
frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version)
|
685
|
+
|
686
|
+
# Try to send it
|
687
|
+
begin
|
688
|
+
@socket.write frame.to_s
|
689
|
+
rescue Errno::EPIPE => e
|
690
|
+
# There has been an error!
|
691
|
+
@pipe_broken = true
|
692
|
+
handle_internal_close(e)
|
693
|
+
end
|
694
|
+
end
|
695
|
+
|
696
|
+
def close(no_sync = false)
|
697
|
+
# If we're already closed, there's no need to do anything - return
|
698
|
+
return if @closed
|
699
|
+
|
700
|
+
# Suspend the session so we don't send heartbeats
|
701
|
+
@session.suspend if @session
|
702
|
+
|
703
|
+
# Send a close frame (if we can)
|
704
|
+
send nil, :close unless @pipe_broken
|
705
|
+
|
706
|
+
# We're officially closed, notify the main loop.
|
707
|
+
# This needs to be synchronised with the getc mutex, so the notification, and especially the actual
|
708
|
+
# close afterwards, don't coincide with the main loop reading something from the SSL socket.
|
709
|
+
# This would cause a segfault due to (I suspect) Ruby bug #12292: https://bugs.ruby-lang.org/issues/12292
|
710
|
+
if no_sync
|
711
|
+
@closed = true
|
712
|
+
else
|
713
|
+
@getc_mutex.synchronize { @closed = true }
|
714
|
+
end
|
715
|
+
|
716
|
+
# Close the socket if possible
|
717
|
+
@socket.close if @socket
|
718
|
+
@socket = nil
|
719
|
+
|
720
|
+
# Make sure we do necessary things as soon as we're closed
|
721
|
+
handle_close(nil)
|
722
|
+
end
|
723
|
+
end
|
724
|
+
end
|