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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +39 -0
  4. data/lib/rubord/components/actionRow.rb +93 -0
  5. data/lib/rubord/components/button.rb +125 -0
  6. data/lib/rubord/components/componentsV2.rb +4 -0
  7. data/lib/rubord/components/containers/base.rb +15 -0
  8. data/lib/rubord/components/containers/container.rb +39 -0
  9. data/lib/rubord/components/containers/section.rb +26 -0
  10. data/lib/rubord/components/containers/separator.rb +51 -0
  11. data/lib/rubord/components/containers/text.rb +23 -0
  12. data/lib/rubord/components/modal.rb +134 -0
  13. data/lib/rubord/components/select_menu.rb +147 -0
  14. data/lib/rubord/models/channel.rb +50 -0
  15. data/lib/rubord/models/collection.rb +70 -0
  16. data/lib/rubord/models/commands/base.rb +111 -0
  17. data/lib/rubord/models/commands/command.rb +3 -0
  18. data/lib/rubord/models/commands/loader.rb +36 -0
  19. data/lib/rubord/models/commands/registry.rb +26 -0
  20. data/lib/rubord/models/components.rb +5 -0
  21. data/lib/rubord/models/embed.rb +87 -0
  22. data/lib/rubord/models/flags.rb +249 -0
  23. data/lib/rubord/models/guild.rb +78 -0
  24. data/lib/rubord/models/interaction.rb +136 -0
  25. data/lib/rubord/models/member.rb +63 -0
  26. data/lib/rubord/models/mention.rb +47 -0
  27. data/lib/rubord/models/message.rb +88 -0
  28. data/lib/rubord/models/role.rb +15 -0
  29. data/lib/rubord/models/user.rb +21 -0
  30. data/lib/rubord/structs/client.rb +364 -0
  31. data/lib/rubord/structs/gateway.rb +363 -0
  32. data/lib/rubord/structs/logger.rb +19 -0
  33. data/lib/rubord/structs/models.rb +19 -0
  34. data/lib/rubord/structs/parser.rb +68 -0
  35. data/lib/rubord/structs/rate_limiter.rb +163 -0
  36. data/lib/rubord/structs/rest.rb +353 -0
  37. data/lib/rubord.rb +8 -0
  38. 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