rubord 0.1.3
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/LICENSE +21 -0
- data/README.md +39 -0
- data/lib/rubord/components/actionRow.rb +93 -0
- data/lib/rubord/components/button.rb +125 -0
- data/lib/rubord/components/componentsV2.rb +4 -0
- data/lib/rubord/components/containers/base.rb +15 -0
- data/lib/rubord/components/containers/container.rb +39 -0
- data/lib/rubord/components/containers/section.rb +26 -0
- data/lib/rubord/components/containers/separator.rb +51 -0
- data/lib/rubord/components/containers/text.rb +23 -0
- data/lib/rubord/components/modal.rb +134 -0
- data/lib/rubord/components/select_menu.rb +147 -0
- data/lib/rubord/models/channel.rb +50 -0
- data/lib/rubord/models/collection.rb +70 -0
- data/lib/rubord/models/commands/base.rb +111 -0
- data/lib/rubord/models/commands/command.rb +3 -0
- data/lib/rubord/models/commands/loader.rb +36 -0
- data/lib/rubord/models/commands/registry.rb +26 -0
- data/lib/rubord/models/components.rb +5 -0
- data/lib/rubord/models/embed.rb +87 -0
- data/lib/rubord/models/flags.rb +249 -0
- data/lib/rubord/models/guild.rb +78 -0
- data/lib/rubord/models/interaction.rb +136 -0
- data/lib/rubord/models/member.rb +63 -0
- data/lib/rubord/models/mention.rb +47 -0
- data/lib/rubord/models/message.rb +88 -0
- data/lib/rubord/models/role.rb +15 -0
- data/lib/rubord/models/user.rb +21 -0
- data/lib/rubord/structs/client.rb +364 -0
- data/lib/rubord/structs/gateway.rb +363 -0
- data/lib/rubord/structs/logger.rb +19 -0
- data/lib/rubord/structs/models.rb +19 -0
- data/lib/rubord/structs/parser.rb +68 -0
- data/lib/rubord/structs/rate_limiter.rb +163 -0
- data/lib/rubord/structs/rest.rb +353 -0
- data/lib/rubord.rb +8 -0
- metadata +105 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
require_relative "rest"
|
|
2
|
+
require_relative "gateway"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
module Rubord
|
|
6
|
+
# Main client class for interacting with the Discord API.
|
|
7
|
+
#
|
|
8
|
+
# The Client serves as the primary interface for creating Discord bots
|
|
9
|
+
# and managing connections to the Discord Gateway and REST API.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a basic bot client
|
|
12
|
+
# client = Rubord::Client.new(prefix: "!", intents: [:messages])
|
|
13
|
+
# client.login("YOUR_BOT_TOKEN")
|
|
14
|
+
#
|
|
15
|
+
# client.on(:ready) do |user|
|
|
16
|
+
# puts "Logged in as #{user.username}"
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# client.on(:message_create) do |message|
|
|
20
|
+
# if message.content.start_with?("!ping")
|
|
21
|
+
# message.reply("Pong!")
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @since 1.0.0
|
|
26
|
+
# @see https://discord.com/developers/docs Discord Developer Documentation
|
|
27
|
+
class Client
|
|
28
|
+
# @return [Rubord::REST] The REST API client instance.
|
|
29
|
+
attr_reader :rest
|
|
30
|
+
|
|
31
|
+
# @return [Rubord::Gateway] The WebSocket gateway connection instance.
|
|
32
|
+
attr_reader :gateway
|
|
33
|
+
|
|
34
|
+
# @return [Rubord::Collection] Collection of cached messages.
|
|
35
|
+
attr_reader :messages
|
|
36
|
+
|
|
37
|
+
# @return [Rubord::Collection] Collection of cached channels.
|
|
38
|
+
attr_reader :channels
|
|
39
|
+
|
|
40
|
+
# @return [Integer] Bitwise value representing enabled Discord intents.
|
|
41
|
+
attr_reader :intents
|
|
42
|
+
|
|
43
|
+
# @return [String] Command prefix for message-based commands.
|
|
44
|
+
attr_reader :prefix
|
|
45
|
+
|
|
46
|
+
# @return [Rubord::User, nil] The bot user account, available after login.
|
|
47
|
+
attr_reader :user
|
|
48
|
+
|
|
49
|
+
# @return [Rubord::Collection] Collection of cached users.
|
|
50
|
+
attr_reader :users
|
|
51
|
+
|
|
52
|
+
# @return [Rubord::Collection] Collection of cached guilds (servers).
|
|
53
|
+
attr_reader :guilds
|
|
54
|
+
|
|
55
|
+
# @return [Rubord::Commands] Commands for the bot.
|
|
56
|
+
attr_reader :commands
|
|
57
|
+
|
|
58
|
+
# Initializes a new Discord client instance.
|
|
59
|
+
#
|
|
60
|
+
# @param prefix [String] The command prefix for message commands.
|
|
61
|
+
# Default is empty string (no prefix required).
|
|
62
|
+
# @param intents [Array<Symbol, Integer>] Discord intents to enable.
|
|
63
|
+
# Can be symbols (e.g., `:guilds`, `:messages`) or integer bit values.
|
|
64
|
+
#
|
|
65
|
+
# @example With prefix and intents
|
|
66
|
+
# client = Rubord::Client.new(
|
|
67
|
+
# prefix: "!",
|
|
68
|
+
# intents: [:guilds, :guild_messages, :message_content]
|
|
69
|
+
# )
|
|
70
|
+
#
|
|
71
|
+
# @example With minimal configuration
|
|
72
|
+
# client = Rubord::Client.new
|
|
73
|
+
#
|
|
74
|
+
# @return [Rubord::Client] A new client instance.
|
|
75
|
+
def initialize(prefix: "", intents: [])
|
|
76
|
+
@token = nil
|
|
77
|
+
@intents = parse_intents(intents)
|
|
78
|
+
@prefix = prefix
|
|
79
|
+
|
|
80
|
+
@rest = nil
|
|
81
|
+
@gateway = nil
|
|
82
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
83
|
+
|
|
84
|
+
@user = nil
|
|
85
|
+
@start_time = Time.now.to_i
|
|
86
|
+
@commands = Rubord::CommandRegistry.new
|
|
87
|
+
|
|
88
|
+
@channels = Rubord::Collection.new
|
|
89
|
+
@messages = Rubord::Collection.new
|
|
90
|
+
@guilds = Rubord::Collection.new
|
|
91
|
+
@users = Rubord::Collection.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the current WebSocket gateway latency in milliseconds.
|
|
95
|
+
#
|
|
96
|
+
# @return [Integer] Gateway latency in ms, or 0 if not connected.
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# puts "Latency: #{client.latency}ms"
|
|
100
|
+
def latency
|
|
101
|
+
@gateway&.latency || 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns the bot's uptime in seconds since login.
|
|
105
|
+
#
|
|
106
|
+
# @return [Integer] Uptime in seconds, or 0 if not logged in.
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# puts "Bot has been running for #{client.uptime} seconds"
|
|
110
|
+
def uptime
|
|
111
|
+
return 0 unless @start_time
|
|
112
|
+
Time.now.to_i - @start_time
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Retrieves the bot application's owner.
|
|
116
|
+
#
|
|
117
|
+
# This method fetches the application owner information from Discord
|
|
118
|
+
# and caches it for subsequent calls.
|
|
119
|
+
#
|
|
120
|
+
# @return [Rubord::User, nil] The application owner user object,
|
|
121
|
+
# or nil if not logged in.
|
|
122
|
+
#
|
|
123
|
+
# @example
|
|
124
|
+
# owner = client.owner
|
|
125
|
+
# puts "Bot owned by: #{owner.username}"
|
|
126
|
+
def owner
|
|
127
|
+
return nil unless @user
|
|
128
|
+
|
|
129
|
+
cached = @users.get(:__owner__)
|
|
130
|
+
return cached if cached
|
|
131
|
+
|
|
132
|
+
data = @rest.get_application
|
|
133
|
+
owner = Rubord::User.new(data["owner"])
|
|
134
|
+
|
|
135
|
+
@users.set(owner.id, owner)
|
|
136
|
+
@users.set(:__owner__, owner)
|
|
137
|
+
|
|
138
|
+
owner
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Authenticates and connects to the Discord API.
|
|
142
|
+
#
|
|
143
|
+
# This method initializes the REST client, connects to the Gateway,
|
|
144
|
+
# and starts processing Discord events.
|
|
145
|
+
#
|
|
146
|
+
# @param token [String] The Discord bot token.
|
|
147
|
+
# Format: "Bot YOUR_TOKEN_HERE" or just "YOUR_TOKEN_HERE".
|
|
148
|
+
#
|
|
149
|
+
# @return [Rubord::Client] Self for method chaining.
|
|
150
|
+
#
|
|
151
|
+
# @raise [InvalidTokenError] If the token is nil or empty.
|
|
152
|
+
#
|
|
153
|
+
# @example Basic login
|
|
154
|
+
# client.login("Bot MTExODg0OTgxOTk0NzMxOTgwOA.G0L2QN.secret")
|
|
155
|
+
#
|
|
156
|
+
# @example With method chaining
|
|
157
|
+
# client
|
|
158
|
+
# .login(token)
|
|
159
|
+
# .on(:ready) { |user| puts "Ready!" }
|
|
160
|
+
def login(token)
|
|
161
|
+
raise InvalidTokenError, "Discord token cannot be empty" if token.nil? || token.strip.empty?
|
|
162
|
+
|
|
163
|
+
@token = token
|
|
164
|
+
@rest = Rubord::REST.new(token)
|
|
165
|
+
@gateway = Rubord::Gateway.new(token, @intents)
|
|
166
|
+
|
|
167
|
+
@gateway.connect do |event, data|
|
|
168
|
+
handle_event(event, data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@start_time = Time.now.to_i
|
|
172
|
+
|
|
173
|
+
self
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Gracefully disconnects from Discord and stops the client.
|
|
177
|
+
#
|
|
178
|
+
# This method closes the WebSocket connection and stops
|
|
179
|
+
# event processing.
|
|
180
|
+
#
|
|
181
|
+
# @return [void]
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
184
|
+
# # Handle graceful shutdown
|
|
185
|
+
# Signal.trap("INT") do
|
|
186
|
+
# puts "Shutting down..."
|
|
187
|
+
# client.stop
|
|
188
|
+
# exit
|
|
189
|
+
# end
|
|
190
|
+
def stop
|
|
191
|
+
@gateway&.close
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def on_ready(&block)
|
|
195
|
+
on(:ready, &block)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @yieldparam message [Rubord::Message]
|
|
199
|
+
def on_message(&block)
|
|
200
|
+
on(:message_create, &block)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @yieldparam interaction [Rubord::Interaction]
|
|
204
|
+
def on_interaction(&block)
|
|
205
|
+
on(:interaction_create, &block)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Registers an event listener callback.
|
|
209
|
+
#
|
|
210
|
+
# @param event [Symbol, String] The event name to listen for.
|
|
211
|
+
# Common events: `:ready`, `:message_create`, `:guild_create`, etc.
|
|
212
|
+
# @yield [*args] The block to execute when the event fires.
|
|
213
|
+
# Block arguments vary by event type.
|
|
214
|
+
#
|
|
215
|
+
# @return [void]
|
|
216
|
+
#
|
|
217
|
+
# @example Listening for ready event
|
|
218
|
+
# client.on(:ready) do |user|
|
|
219
|
+
# puts "Logged in as #{user.username}"
|
|
220
|
+
# end
|
|
221
|
+
#
|
|
222
|
+
# @example Listening for messages
|
|
223
|
+
# client.on(:message_create) do |message|
|
|
224
|
+
# if message.content == "ping"
|
|
225
|
+
# message.reply("pong")
|
|
226
|
+
# end
|
|
227
|
+
# end
|
|
228
|
+
#
|
|
229
|
+
# @see #trigger For event triggering mechanism
|
|
230
|
+
def on(event, &block)
|
|
231
|
+
@listeners[event.to_sym] << block
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Fetches a channel from Discord API and caches it.
|
|
235
|
+
#
|
|
236
|
+
# @param channel_id [String, Integer] The Discord channel ID to fetch.
|
|
237
|
+
#
|
|
238
|
+
# @return [Rubord::Channel] The channel object.
|
|
239
|
+
#
|
|
240
|
+
# @example
|
|
241
|
+
# channel = client.fetch_channel("123456789012345678")
|
|
242
|
+
# puts "Channel name: #{channel.name}"
|
|
243
|
+
def fetch_channel(channel_id)
|
|
244
|
+
data = @rest.get_channel(channel_id)
|
|
245
|
+
channel = Rubord::Channel.new(data, self)
|
|
246
|
+
@channels.set(channel.id, channel)
|
|
247
|
+
channel
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Fetches a message from Discord API and caches it.
|
|
251
|
+
#
|
|
252
|
+
# @param channel_id [String, Integer] The ID of the channel containing the message.
|
|
253
|
+
# @param message_id [String, Integer] The ID of the message to fetch.
|
|
254
|
+
#
|
|
255
|
+
# @return [Rubord::Message] The message object.
|
|
256
|
+
#
|
|
257
|
+
# @example
|
|
258
|
+
# message = client.fetch_message("123456789012345678", "987654321098765432")
|
|
259
|
+
# puts "Message content: #{message.content}"
|
|
260
|
+
def fetch_message(channel_id, message_id)
|
|
261
|
+
data = @rest.get_message(channel_id, message_id)
|
|
262
|
+
msg = Rubord::Message.new(data, self)
|
|
263
|
+
@messages.set(msg.id, msg)
|
|
264
|
+
msg
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def process_command(message)
|
|
270
|
+
return if message.author.bot
|
|
271
|
+
return unless @prefix && !@prefix.empty?
|
|
272
|
+
return unless message.content.start_with?(@prefix)
|
|
273
|
+
|
|
274
|
+
input = message.content[@prefix.length..].strip
|
|
275
|
+
return if input.empty?
|
|
276
|
+
|
|
277
|
+
name, *args = input.split(/\s+/)
|
|
278
|
+
name.downcase!
|
|
279
|
+
|
|
280
|
+
command = @commands.get(name)
|
|
281
|
+
return unless command
|
|
282
|
+
|
|
283
|
+
command.run(message, args = args)
|
|
284
|
+
rescue => e
|
|
285
|
+
Rubord::Logger.warn "[Rubord:Command Error] #{e.class}: #{e.message}"
|
|
286
|
+
Rubord::Logger.warn e.backtrace.join("\n")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Internal event handler for gateway events.
|
|
290
|
+
#
|
|
291
|
+
# This method processes raw gateway events, creates appropriate
|
|
292
|
+
# objects, updates caches, and triggers registered listeners.
|
|
293
|
+
#
|
|
294
|
+
# @param event [Symbol] The gateway event type.
|
|
295
|
+
# @param data [Hash] The event data from Discord.
|
|
296
|
+
#
|
|
297
|
+
# @return [void]
|
|
298
|
+
#
|
|
299
|
+
# @see #trigger
|
|
300
|
+
def handle_event(event, data)
|
|
301
|
+
case event
|
|
302
|
+
when :ready
|
|
303
|
+
@user = Rubord::User.new(data["user"])
|
|
304
|
+
@users.set(@user.id, @user)
|
|
305
|
+
trigger(:ready, @user)
|
|
306
|
+
|
|
307
|
+
when :guild_create
|
|
308
|
+
guild = Rubord::Guild.new(data, self)
|
|
309
|
+
@guilds.set(guild.id, guild)
|
|
310
|
+
|
|
311
|
+
if data["members"]
|
|
312
|
+
data["members"].each do |m|
|
|
313
|
+
guild.add_member(m)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
when :guild_member_add
|
|
318
|
+
guild = @guilds.get(data["guild_id"])
|
|
319
|
+
guild&.add_member(data)
|
|
320
|
+
|
|
321
|
+
when :message_create
|
|
322
|
+
msg = Rubord::Message.new(data, self)
|
|
323
|
+
@messages.set(msg.id, msg)
|
|
324
|
+
|
|
325
|
+
process_command(msg)
|
|
326
|
+
trigger(:message_create, msg)
|
|
327
|
+
|
|
328
|
+
when :interaction_create
|
|
329
|
+
interaction = Rubord::Interaction.new(data, self)
|
|
330
|
+
trigger(:interaction_create, interaction)
|
|
331
|
+
else
|
|
332
|
+
trigger(event, data)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Triggers all registered listeners for an event.
|
|
337
|
+
#
|
|
338
|
+
# @param event [Symbol] The event to trigger.
|
|
339
|
+
# @param args [Array] Arguments to pass to listeners.
|
|
340
|
+
#
|
|
341
|
+
# @return [void]
|
|
342
|
+
#
|
|
343
|
+
# @note Listeners can accept either `(client, *args)` or just `(*args)`
|
|
344
|
+
# depending on their arity.
|
|
345
|
+
def trigger(event, payload)
|
|
346
|
+
return unless @listeners[event]
|
|
347
|
+
|
|
348
|
+
@listeners[event].each do |cb|
|
|
349
|
+
cb.call(payload)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Parses and combines intent symbols/values into a bitwise integer.
|
|
354
|
+
#
|
|
355
|
+
# @param intents [Array<Symbol, Integer>] Array of intent symbols or values.
|
|
356
|
+
#
|
|
357
|
+
# @return [Integer] Combined bitwise intent value.
|
|
358
|
+
#
|
|
359
|
+
# @see Rubord::Intents.combine
|
|
360
|
+
def parse_intents(intents)
|
|
361
|
+
Rubord::Intents.combine(intents)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
require "websocket-client-simple"
|
|
2
|
+
require "json"
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Rubord
|
|
6
|
+
class Gateway
|
|
7
|
+
GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
8
|
+
|
|
9
|
+
OPCODE_DISPATCH = 0
|
|
10
|
+
OPCODE_HEARTBEAT = 1
|
|
11
|
+
OPCODE_IDENTIFY = 2
|
|
12
|
+
OPCODE_HELLO = 10
|
|
13
|
+
OPCODE_HEARTBEAT_ACK = 11
|
|
14
|
+
OPCODE_RECONNECT = 7
|
|
15
|
+
OPCODE_INVALID_SESSION = 9
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
attr_reader :latency, :session_id, :seq
|
|
19
|
+
|
|
20
|
+
def initialize(token, intents)
|
|
21
|
+
@token = token
|
|
22
|
+
@intents = intents
|
|
23
|
+
@seq = nil
|
|
24
|
+
@session_id = nil
|
|
25
|
+
@ws = nil
|
|
26
|
+
@stopping = false
|
|
27
|
+
@connected = false
|
|
28
|
+
|
|
29
|
+
@heartbeat_interval = nil
|
|
30
|
+
@last_heartbeat_at = nil
|
|
31
|
+
@latency = 0
|
|
32
|
+
@heartbeat_thread = nil
|
|
33
|
+
@event_handlers = {}
|
|
34
|
+
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def connect(&block)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
return if @connected && !@stopping
|
|
41
|
+
|
|
42
|
+
if @token.nil? || @token.strip.empty?
|
|
43
|
+
raise InvalidTokenError, "Discord token cannot be empty"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@stopping = false
|
|
47
|
+
@connected = false
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
@ws = WebSocket::Client::Simple.connect(GATEWAY_URL, headers: {
|
|
51
|
+
"User-Agent" => "DiscordBot (https://github.com/kauzxx00/rubord, 1.0.0)",
|
|
52
|
+
})
|
|
53
|
+
rescue => e
|
|
54
|
+
Rubord::Logger.error "[Rubord:Gateway] Failed to connect to Discord Gateway: #{e.message}"
|
|
55
|
+
schedule_reconnect
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
gateway_instance = self
|
|
60
|
+
|
|
61
|
+
@ws.on(:open) do
|
|
62
|
+
gateway_instance.handle_open
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@ws.on(:message) do |event|
|
|
66
|
+
gateway_instance.handle_message(event, &block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@ws.on(:close) do |event|
|
|
70
|
+
gateway_instance.handle_close(event)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@ws.on(:error) do |e|
|
|
74
|
+
gateway_instance.handle_error(e)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sleep 1 while !@stopping
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_open
|
|
82
|
+
@latency = 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_message(event, &block)
|
|
86
|
+
data = event.data.to_s
|
|
87
|
+
|
|
88
|
+
if data.nil? || data.strip.empty?
|
|
89
|
+
Rubord::Logger.warn "[Rubord:Gateway] Received empty payload"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
payload = JSON.parse(data)
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
Rubord::Logger.warn "[Rubord:Gateway] Failed to parse JSON: #{e.message}"
|
|
97
|
+
Rubord::Logger.warn "[Rubord:Gateway] Raw data: #{data.inspect[0..100]}" if data && data.length > 0
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
op = payload["op"]
|
|
102
|
+
t = payload["t"]
|
|
103
|
+
d = payload["d"]
|
|
104
|
+
s = payload["s"]
|
|
105
|
+
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@seq = s if s && s > 0
|
|
108
|
+
|
|
109
|
+
case op
|
|
110
|
+
when OPCODE_HELLO
|
|
111
|
+
handle_hello(d)
|
|
112
|
+
when OPCODE_HEARTBEAT_ACK
|
|
113
|
+
handle_heartbeat_ack
|
|
114
|
+
when OPCODE_RECONNECT
|
|
115
|
+
Rubord::Logger.warn "[Rubord:Gateway] Discord requested reconnect"
|
|
116
|
+
schedule_reconnect
|
|
117
|
+
when OPCODE_INVALID_SESSION
|
|
118
|
+
handle_invalid_session(d)
|
|
119
|
+
when OPCODE_DISPATCH
|
|
120
|
+
handle_dispatch(t, d, &block)
|
|
121
|
+
else
|
|
122
|
+
Rubord::Logger.warn "[Rubord:Gateway] Unhandled opcode: #{op}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue => e
|
|
126
|
+
Rubord::Logger.warn "[Rubord:Gateway] Error processing message: #{e.message}"
|
|
127
|
+
Rubord::Logger.warn e.backtrace.join("\n") if e.backtrace
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_close(event)
|
|
131
|
+
Rubord::Logger.warn "[Rubord:Gateway] WebSocket connection closed: code=#{event.code}, reason=#{event.reason}"
|
|
132
|
+
cleanup_connection
|
|
133
|
+
schedule_reconnect unless @stopping
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_error(e)
|
|
137
|
+
Rubord::Logger.warn "[Rubord:Gateway] WebSocket error: #{e.message}"
|
|
138
|
+
Rubord::Logger.warn e.backtrace.join("\n") if e.backtrace
|
|
139
|
+
cleanup_connection
|
|
140
|
+
schedule_reconnect unless @stopping
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handle_hello(data)
|
|
144
|
+
@heartbeat_interval = data["heartbeat_interval"].to_f / 1000.0
|
|
145
|
+
@connected = true
|
|
146
|
+
|
|
147
|
+
if @session_id && @seq
|
|
148
|
+
resume_connection
|
|
149
|
+
else
|
|
150
|
+
start_heartbeat
|
|
151
|
+
identify
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_heartbeat_ack
|
|
156
|
+
if @last_heartbeat_at
|
|
157
|
+
@latency = ((Time.now - @last_heartbeat_at) * 1000).round
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_invalid_session(resumable)
|
|
162
|
+
Rubord::Logger.warn "[Rubord:Gateway] Invalid session (resumable: #{resumable})"
|
|
163
|
+
|
|
164
|
+
if resumable && @session_id && @seq
|
|
165
|
+
Rubord::Logger.warn "[Rubord:Gateway] Attempting to resume session"
|
|
166
|
+
sleep rand(1..3)
|
|
167
|
+
identify
|
|
168
|
+
else
|
|
169
|
+
Rubord::Logger.warn "[Rubord:Gateway] Starting new session"
|
|
170
|
+
@session_id = nil
|
|
171
|
+
@seq = nil
|
|
172
|
+
sleep rand(1..5)
|
|
173
|
+
identify
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def handle_dispatch(event_type, data, &block)
|
|
178
|
+
if event_type == "READY"
|
|
179
|
+
@session_id = data["session_id"]
|
|
180
|
+
@connected = true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
event_map = {
|
|
184
|
+
"READY" => :ready,
|
|
185
|
+
"RESUMED" => :resumed,
|
|
186
|
+
"MESSAGE_CREATE" => :message_create,
|
|
187
|
+
"MESSAGE_UPDATE" => :message_update,
|
|
188
|
+
"MESSAGE_DELETE" => :message_delete,
|
|
189
|
+
"GUILD_CREATE" => :guild_create,
|
|
190
|
+
"GUILD_DELETE" => :guild_delete,
|
|
191
|
+
"CHANNEL_CREATE" => :channel_create,
|
|
192
|
+
"CHANNEL_DELETE" => :channel_delete,
|
|
193
|
+
"INTERACTION_CREATE" => :interaction_create,
|
|
194
|
+
"MESSAGE_REACTION_ADD" => :reaction_add,
|
|
195
|
+
"MESSAGE_REACTION_REMOVE" => :reaction_remove,
|
|
196
|
+
"TYPING_START" => :typing_start,
|
|
197
|
+
"PRESENCE_UPDATE" => :presence_update,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
event_symbol = event_map[event_type]
|
|
201
|
+
|
|
202
|
+
if event_symbol && block_given?
|
|
203
|
+
begin
|
|
204
|
+
block.call(event_symbol, data)
|
|
205
|
+
rescue => e
|
|
206
|
+
Rubord::Logger.warn "[Rubord:Gateway] Error in event handler for #{event_type}: #{e.message}"
|
|
207
|
+
Rubord::Logger.warn e.full_message
|
|
208
|
+
end
|
|
209
|
+
elsif event_type && !["PRESENCE_UPDATE", "TYPING_START", "GUILD_MEMBER_UPDATE"].include?(event_type)
|
|
210
|
+
Rubord::Logger.warn "[Rubord:Gateway] Unhandled event: #{event_type}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def identify
|
|
215
|
+
payload = {
|
|
216
|
+
op: OPCODE_IDENTIFY,
|
|
217
|
+
d: {
|
|
218
|
+
token: @token,
|
|
219
|
+
intents: @intents,
|
|
220
|
+
properties: {
|
|
221
|
+
"$os": "linux",
|
|
222
|
+
"$browser": "Rubord",
|
|
223
|
+
"$device": "Rubord",
|
|
224
|
+
},
|
|
225
|
+
compress: false,
|
|
226
|
+
large_threshold: 250,
|
|
227
|
+
shard: [0, 1],
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
send_payload(payload)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def resume_connection
|
|
235
|
+
Rubord::Logger.warn "[Rubord:Gateway] Attempting to resume session #{@session_id} at seq #{@seq}"
|
|
236
|
+
|
|
237
|
+
payload = {
|
|
238
|
+
op: 6,
|
|
239
|
+
d: {
|
|
240
|
+
token: @token,
|
|
241
|
+
session_id: @session_id,
|
|
242
|
+
seq: @seq,
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
send_payload(payload)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def start_heartbeat
|
|
250
|
+
return unless @heartbeat_interval && @heartbeat_interval > 0
|
|
251
|
+
|
|
252
|
+
@heartbeat_thread&.kill rescue nil
|
|
253
|
+
|
|
254
|
+
@heartbeat_thread = Thread.new do
|
|
255
|
+
while !@stopping && @connected
|
|
256
|
+
begin
|
|
257
|
+
sleep @heartbeat_interval
|
|
258
|
+
|
|
259
|
+
break if @stopping || !@connected
|
|
260
|
+
|
|
261
|
+
if @last_heartbeat_at && (Time.now - @last_heartbeat_at) > (@heartbeat_interval * 3)
|
|
262
|
+
Rubord::Logger.warn "[Rubord:Gateway] No heartbeat ACK received, reconnecting..."
|
|
263
|
+
schedule_reconnect
|
|
264
|
+
break
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
heartbeat_payload = { op: OPCODE_HEARTBEAT, d: @seq }
|
|
268
|
+
@last_heartbeat_at = Time.now
|
|
269
|
+
send_payload(heartbeat_payload)
|
|
270
|
+
|
|
271
|
+
Rubord::Logger.warn "[Rubord:Gateway] Sent heartbeat (seq: #{@seq})" if ENV["DEBUG"]
|
|
272
|
+
rescue => e
|
|
273
|
+
Rubord::Logger.warn "[Rubord:Gateway] Heartbeat error: #{e.message}"
|
|
274
|
+
schedule_reconnect
|
|
275
|
+
break
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def send_payload(payload)
|
|
282
|
+
return if @stopping || !@ws
|
|
283
|
+
|
|
284
|
+
begin
|
|
285
|
+
json = payload.to_json
|
|
286
|
+
@ws.send(json)
|
|
287
|
+
|
|
288
|
+
if ENV["DEBUG"]
|
|
289
|
+
opcode_name = case payload[:op] || payload["op"]
|
|
290
|
+
when 1 then "HEARTBEAT"
|
|
291
|
+
when 2 then "IDENTIFY"
|
|
292
|
+
when 6 then "RESUME"
|
|
293
|
+
else "OP#{payload[:op] || payload["op"]}"
|
|
294
|
+
end
|
|
295
|
+
Rubord::Logger.warn "[Rubord:Gateway] Sent #{opcode_name}"
|
|
296
|
+
end
|
|
297
|
+
rescue => e
|
|
298
|
+
Rubord::Logger.warn "[Rubord:Gateway] Failed to send payload: #{e.message}"
|
|
299
|
+
schedule_reconnect
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def schedule_reconnect
|
|
304
|
+
return if @stopping
|
|
305
|
+
|
|
306
|
+
Thread.new do
|
|
307
|
+
@mutex.synchronize do
|
|
308
|
+
cleanup_connection
|
|
309
|
+
|
|
310
|
+
delay = 1
|
|
311
|
+
max_delay = 30
|
|
312
|
+
|
|
313
|
+
while !@stopping
|
|
314
|
+
Rubord::Logger.warn "[Rubord:Gateway] Reconnecting in #{delay}s..."
|
|
315
|
+
sleep delay
|
|
316
|
+
|
|
317
|
+
begin
|
|
318
|
+
connect
|
|
319
|
+
break if @connected
|
|
320
|
+
rescue => e
|
|
321
|
+
Rubord::Logger.warn "[Rubord:Gateway] Reconnect failed: #{e.message}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
delay = [delay * 2, max_delay].min
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def cleanup_connection
|
|
331
|
+
@connected = false
|
|
332
|
+
|
|
333
|
+
@heartbeat_thread&.kill rescue nil
|
|
334
|
+
@heartbeat_thread = nil
|
|
335
|
+
|
|
336
|
+
begin
|
|
337
|
+
@ws&.close if @ws.respond_to?(:close)
|
|
338
|
+
rescue
|
|
339
|
+
end
|
|
340
|
+
@ws = nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def reconnect
|
|
344
|
+
Rubord::Logger.warn "[Rubord:Gateway] Manual reconnect requested"
|
|
345
|
+
schedule_reconnect
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def disconnect
|
|
349
|
+
Rubord::Logger.warn "[Rubord:Gateway] Disconnecting..."
|
|
350
|
+
|
|
351
|
+
@mutex.synchronize do
|
|
352
|
+
@stopping = true
|
|
353
|
+
@connected = false
|
|
354
|
+
|
|
355
|
+
cleanup_connection
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def connected?
|
|
360
|
+
@connected && !@stopping
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|