stoatrb 0.1.0 → 0.2.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.
data/lib/stoatrb/bot.rb CHANGED
@@ -1,589 +1,678 @@
1
- # lib/stoatrb/bot.rb
2
- require 'json'
3
- require 'net/http'
4
- require 'websocket-client-simple'
5
- require 'thread'
6
- require 'time'
7
- require_relative 'debuglogger'
8
- require_relative 'request_queue'
9
- require_relative 'webhooks'
10
-
11
- module Stoatrb
12
- class StoatBot
13
- EMOJI_MAP = {
14
- ':grinning:' => '😀',
15
- ':heart:' => '❤️',
16
- ':joy:' => '😂',
17
- ':unamused:' => '😒',
18
- ':sunglasses:' => '😎',
19
- ':thinking:' => '🤔',
20
- ':clap:' => '👏',
21
- ':thumbsup:' => '👍',
22
- ':thumbsdown:' => '👎',
23
- ':point_up:' => '☝️',
24
- ':+1:' => '👍',
25
- ':-1:' => '👎'
26
- }.freeze
27
-
28
- attr_reader :token, :user_id, :bot_name, :servers, :prefix, :bot_owner_id, :bot_discriminator, :bot_discoverable, :bot_creation_date, :webhooks
29
- attr_accessor :websocket_url, :api_url, :cdn_url
30
- # Initializes the bot with the provided token, API endpoints, and configuration.
31
- def initialize(token, api_url: 'https://api.stoat.chat/0.8', websocket_url: 'wss://events.stoat.chat', cdn_url: 'https://cdn.stoatusercontent.com', prefix: nil, debuglogs: false, selfbot: false)
32
- @token = token
33
- @api_url = api_url
34
- @websocket_url = websocket_url
35
- @cdn_url = cdn_url
36
-
37
- @user_id = nil
38
- @bot_name = nil
39
- @servers = {}
40
- @commands = {}
41
- @message_handlers = []
42
-
43
- @websocket = nil
44
- @websocket_thread = nil
45
- @heartbeat_interval = 30 # Default heartbeat interval in seconds
46
- @last_heartbeat_sent = Time.now.to_i
47
- @running = false
48
- @ready_event_received = false
49
- @logger = Stoatrb::DebugLogger.new(debuglogs)
50
- @request_queue = Stoatrb::RequestQueue.new(500)
51
- @webhooks = Stoatrb::Webhooks.new(api_url, @logger, token, selfbot)
52
-
53
- @prefix = "!"
54
- @prefix = prefix if prefix
55
- @selfbot = selfbot
56
- @logger.debug "Stoat.chat Bot initialized. API: #{@api_url}, WS: #{@websocket_url}, CDN: #{@cdn_url}, Prefix: #{@prefix}"
57
- end
58
-
59
- def get_botinfo
60
- {
61
- 'bot_id' => @user_id,
62
- 'bot_name' => @bot_name,
63
- 'bot_discriminator' => @bot_discriminator,
64
- 'bot_ownerid' => @bot_owner_id,
65
- 'bot_creationdate' => @bot_creation_date,
66
- 'bot_flags' => @bot_flags,
67
- 'bot_discoverable' => @bot_discoverable,
68
- 'bot_public' => @bot_public,
69
- 'bot_analytics' => @bot_analytics,
70
- 'bot_prefix' => @prefix,
71
- 'bot_token' => @token
72
- }
73
- end
74
-
75
- # Log into the Stoat.chat bot
76
- def login
77
- @logger.debug "BOT: Bot attempting to start...."
78
- # Step 1: Fetch initial bot user details via REST API using /users/@me with X-Bot-Token | As confirmed, /users/@me returns the bot's user object directly.
79
- uri = URI("#{@api_url}/users/@me")
80
- req = Net::HTTP::Get.new(uri)
81
- _add_auth_header(req)
82
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
83
- http.request(req)
84
- end
85
-
86
- if res.is_a?(Net::HTTPSuccess)
87
- bot_user_data = JSON.parse(res.body)
88
- @logger.debug "Response Body (successful login attempt from /users/@me): #{res.body}"
89
- @user_id = bot_user_data&.[]('_id')
90
- @bot_name = bot_user_data&.[]('username')
91
- @bot_discriminator = bot_user_data&.[]('discriminator')
92
- bot_specific_info = bot_user_data&.[]('bot')
93
- @bot_owner_id = bot_specific_info&.[]('owner')
94
- @bot_flags = bot_specific_info&.[]('flags')
95
- @bot_discoverable = nil
96
- @bot_public = nil
97
- @bot_analytics = nil
98
- @bot_creation_date = Time.at(bot_user_data&.[]('created_at').to_i / 1000) rescue nil
99
- if @user_id.nil? || @bot_name.nil?
100
- @logger.debug "AN ERROR HAS OCCURED: Essential properties (_id, username) missing or nil in API response from /users/@me. Please inspect the 'Response Body' above for unexpected format."
101
- return false
102
- end
103
-
104
- @logger.debug "Successfully identified as #{@bot_name} (ID: #{@user_id}) via REST API."
105
- @logger.debug "Bot Owner ID: #{@bot_owner_id}, Owner ID: #{@bot_owner_id}, Discoverable: #{@bot_discoverable.inspect}, Created: #{@bot_creation_date}"
106
- else
107
- @logger.debug "AN ERROR HAS OCCURED: Initial REST API call failed.... #{res.message} (Code: #{res.code})"
108
- @logger.debug "Please check your bot token. Cannot proceed with WebSocket connection. Response Body: #{res.body}"
109
- return false
110
- end
111
- # Step 2: Connect to the WebSocket
112
- @running = true
113
- connect_websocket
114
- @request_queue.start_processing
115
- true
116
- end
117
- def run
118
- begin
119
- unless login
120
- puts "Bot failed to log in. Exiting."
121
- exit(1)
122
- end
123
- puts "Bot is online and running. Press Ctrl+C to stop."
124
- @websocket_thread.join
125
- rescue Interrupt
126
- puts "\nCtrl+C detected. Shutting down bot gracefully..."
127
- rescue => e
128
- puts "An unhandled error occurred in the main script loop: #{e.message}"
129
- puts e.backtrace.join("\n")
130
- ensure
131
- stop
132
- puts "Bot process ended."
133
- end
134
- end
135
-
136
- def set_presence(status)
137
- valid_statuses = ['Online', 'Idle', 'Dnd', 'Focus', 'Invisible']
138
- unless valid_statuses.include?(status)
139
- @logger.debug "AN ERROR HAS OCCURED: Invalid status '#{status}'. Must be one of the following choices: #{valid_statuses.join(', ')}."
140
- return false
141
- end
142
-
143
- @logger.debug "Attempting to set bot presence to '#{status}'."
144
- uri = URI("#{@api_url}/users/@me")
145
- req = Net::HTTP::Patch.new(uri)
146
- _add_auth_header(req)
147
- req['Content-Type'] = 'application/json'
148
- payload = {
149
- status: {
150
- presence: status
151
- }
152
- }
153
- req.body = payload.to_json
154
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
155
- http.request(req)
156
- end
157
-
158
- if res.is_a?(Net::HTTPSuccess)
159
- @logger.debug "BOT: Presence has been changed to '#{status}'."
160
- true
161
- else
162
- @logger.debug "AN ERROR HAS OCCURED: Failed to update presence. Response: #{res.message} (Code: #{res.code})"
163
- @logger.debug "Response Body: #{res.body}"
164
- false
165
- end
166
- rescue => e
167
- @logger.debug "AN ERROR HAS OCCURED: Error setting presence: #{e.message}"
168
- @logger.debug e.backtrace.join("\n")
169
- false
170
- end
171
-
172
- def connect_websocket
173
- if @websocket && @websocket.open? && @running
174
- @logger.debug "WebSocket already open and running."
175
- return
176
- end
177
-
178
- @logger.debug "Connecting to WebSocket: #{@websocket_url}"
179
- ws_url_with_token = "#{@websocket_url}?token=#{@token}"
180
- bot_instance = self
181
- thread_logger = @logger
182
-
183
- @websocket_thread = Thread.new do
184
- begin
185
- @websocket = WebSocket::Client::Simple.connect ws_url_with_token
186
- @websocket.on :open do
187
- thread_logger.debug "WebSocket connection opened!"
188
- end
189
- @websocket.on :message do |msg|
190
- bot_instance.handle_websocket_message(msg.data)
191
- end
192
- @websocket.on :close do |e|
193
- close_code = e&.code || 'N/A'
194
- close_reason = e&.reason || 'No reason provided'
195
- thread_logger.debug "WebSocket closed: #{close_code} - #{close_reason}."
196
- bot_instance.instance_variable_set(:@websocket, nil)
197
- if bot_instance.instance_variable_get(:@running)
198
- thread_logger.debug "Attempting to reconnect in 5 seconds..."
199
- sleep 5
200
- bot_instance.connect_websocket
201
- else
202
- thread_logger.debug "BOT: Bot has stopped and will not try to reconnect" # Use local thread_logger
203
- end
204
- end
205
- @websocket.on :error do |e|
206
- error_message = e&.message || 'Unknown error'
207
- thread_logger.debug "WebSocket error: #{error_message}"
208
- @websocket.close if @websocket&.open?
209
- end
210
-
211
- while bot_instance.instance_variable_get(:@running)
212
- if Time.now.to_i - @last_heartbeat_sent > @heartbeat_interval
213
- bot_instance.send_heartbeat
214
- end
215
- sleep 1
216
- end
217
- thread_logger.debug "WebSocket thread loop finished."
218
- rescue => e
219
- thread_logger.debug "WebSocket thread unhandled exception: #{e.message}"
220
- thread_logger.debug e.backtrace.join("\n")
221
- bot_instance.instance_variable_set(:@websocket, nil)
222
- if bot_instance.instance_variable_get(:@running)
223
- thread_logger.debug "Attempting to reconnect in 5 seconds due to unhandled error..."
224
- sleep 5
225
- bot_instance.connect_websocket
226
- else
227
- thread_logger.debug "Bot is stopped, not attempting to reconnect after unhandled error."
228
- end
229
- end
230
- end
231
- sleep 1
232
- end
233
-
234
- def send_heartbeat
235
- if @websocket && @websocket.open?
236
- payload = { type: 'Ping', data: Time.now.to_i }
237
- @websocket.send(payload.to_json)
238
- @last_heartbeat_sent = Time.now.to_i
239
- end
240
- rescue OpenSSL::SSL::SSLError => e
241
- @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat (SSL): #{e.message}"
242
- @websocket&.close
243
- rescue => e
244
- @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat: #{e.message}"
245
- @websocket&.close
246
- end
247
-
248
- def handle_websocket_message(raw_data)
249
- begin
250
- event = JSON.parse(raw_data)
251
- event_type = event['type']
252
- case event_type
253
- when 'Ready'
254
- @logger.debug "Received 'Ready' event. Populating initial data..."
255
- if event['servers']
256
- event['servers'].each do |server_data|
257
- @servers[server_data['_id']] = {
258
- 'name' => server_data['name'],
259
- 'id' => server_data['_id']
260
- }
261
- @logger.debug "Stored server ID from Ready event: #{server_data['_id'].inspect}" # New debug
262
- end
263
- @logger.debug "Loaded #{event['servers'].count} real servers from 'Ready' event."
264
- else
265
- @logger.debug "'Ready' event received but no 'servers' array found."
266
- end
267
- @ready_event_received = true
268
- @logger.debug "@ready_event_received set to true."
269
- when 'Message'
270
- unless event['author'] == @user_id
271
- process_message(event)
272
- end
273
- when 'Authenticated'
274
- @logger.debug "Successfully authenticated with WebSocket."
275
- when 'Pong'
276
- # @logger.debug "Received Pong response."
277
- when 'Error'
278
- @logger.debug "AN ERROR HAS OCCURED: Stoat.chat API Error received via WebSocket: #{event['error']}"
279
- else
280
- # @logger.debug "AN ERROR HAS OCCURED: Unhandled WebSocket event type: #{event_type}"
281
- end
282
- rescue JSON::ParserError => e
283
- @logger.debug "AN ERROR HAS OCCURED: Failed to parse WebSocket message as JSON: #{e.message}"
284
- @logger.debug "Raw message: #{raw_data}"
285
- rescue => e
286
- @logger.debug "AN ERROR HAS OCCURED: Error processing WebSocket message: #{e.message}"
287
- @logger.debug e.backtrace.join("\n")
288
- end
289
- end
290
-
291
- def on_message(&block)
292
- @message_handlers << block
293
- end
294
- def command(command_name, required_permissions: [], nsfw_channel_required: false, &block)
295
- cmd_key = command_name.to_s.downcase
296
- @commands[cmd_key] = {
297
- 'block' => block,
298
- 'permissions' => required_permissions,
299
- 'nsfw_cmd' => nsfw_channel_required
300
- }
301
- @logger.debug "Command '#{command_name}' registered! Permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
302
- end
303
-
304
- def check_permissions(message, required_permissions, nsfw_channel_required)
305
- @logger.debug "check_permissions called with required_permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
306
- @logger.debug "message['channel'] value: #{message['channel'].inspect}"
307
-
308
- server_id_from_message = message['member']&.[]('_id')&.[]('server')
309
- @logger.debug "message['member']['_id']['server'] value: #{server_id_from_message.inspect}"
310
-
311
- if required_permissions.empty? && !nsfw_channel_required
312
- @logger.debug "Permission Check: No specific permissions or NSFW requirement. Allowing command."
313
- return true
314
- end
315
- user_id = message['author']
316
-
317
- # --- Permission Check ---
318
- @logger.debug "PERMISSION DEBUG: User ID from message (user_id): '#{user_id}' | Bot Owner ID stored (@bot_owner_id): '#{@bot_owner_id}'"
319
- @logger.debug "Are they equal? (user_id == @bot_owner_id): #{user_id == @bot_owner_id}"
320
- # @logger.debug "User ID char codes: #{user_id.each_char.map(&:ord).join(', ')}"
321
- # @logger.debug "Bot Owner ID char codes: #{@bot_owner_id.each_char.map(&:ord).join(', ')}"
322
- # --- BotOwner Check ---
323
- if required_permissions.include?('BotOwner')
324
- if user_id == @bot_owner_id
325
- @logger.debug "Permission Check: User is the bot owner. Allowing command."
326
- return true
327
- else
328
- @logger.debug "Permission Check: User is NOT the bot owner. Denying command."
329
- return false
330
- end
331
- end
332
- # --- NSFW Channel Check ---
333
- channel_id = message['channel']
334
- if server_id_from_message.nil?
335
- if nsfw_channel_required
336
- @logger.debug "Permission Check: Command requires NSFW channel, but message is in DM. Denying command."
337
- return false
338
- end
339
- else
340
- channel_details = get_channel_details(channel_id)
341
- if channel_details.nil?
342
- @logger.debug "Permission Check: Command requires NSFW channel but could not retrieve channel details. Denying command."
343
- return false
344
- end
345
- is_channel_nsfw = channel_details['nsfw'] || false
346
- if nsfw_channel_required && !is_channel_nsfw
347
- @logger.debug "Permission Check: Command requires NSFW channel, but current channel is NOT NSFW marked. Denying."
348
- return false
349
- elsif !nsfw_channel_required && is_channel_nsfw
350
- @logger.debug "Permission Check: Command does not require NSFW channel, but is in NSFW marked channel. Allowing."
351
- end
352
- end
353
-
354
- if !required_permissions.empty?
355
- @logger.debug "User #{user_id} is not the bot owner. Requires permissions: #{required_permissions.inspect}. (Full permission check for non-owner, server-specific permissions not implemented)."
356
- return false
357
- end
358
- @logger.debug "Permission Check: All checks passed. Allowing command."
359
- true
360
- end
361
-
362
- def process_message(message)
363
- unless @ready_event_received
364
- @logger.debug "AN ERROR HAS OCCURED: Bot is not ready for commands"
365
- return
366
- end
367
-
368
- unless @selfbot
369
- return if message['author'] == @user_id
370
- end
371
-
372
- content = message['content']&.strip
373
- return if content.nil? || content.empty?
374
- @logger.debug "Full Message object received in process_message: #{message.inspect}"
375
-
376
- @commands.each do |cmd_name, cmd_data|
377
- command_full_string = "#{@prefix}#{cmd_name}"
378
- if content.downcase.start_with?(command_full_string.downcase)
379
- args_string = content[command_full_string.length..]&.strip
380
- args = args_string.to_s.split(/\s+/)
381
- args = [] if args == ['']
382
- # --- Permission Check (Pass both permissions and nsfw_channel_required to check_permissions) ---
383
- if check_permissions(message, cmd_data['permissions'], cmd_data['nsfw_cmd'])
384
- @logger.debug "Executing command: '#{cmd_name}' with args: #{args.inspect}"
385
- cmd_data['block'].call(message, args)
386
- else
387
- channel_id = message['channel']
388
- if cmd_data['nsfw_cmd'] && message['member']
389
- channel_details = get_channel_details(channel_id)
390
- if channel_details && !channel_details['nsfw']
391
- self.send_message(channel_id, text: "⛔ A NSFW marked channel is required to use the following command: `#{cmd_name}`")
392
- else
393
- self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
394
- end
395
- else
396
- self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
397
- end
398
- end
399
- return
400
- end
401
- end
402
-
403
- @message_handlers.each do |handler|
404
- @logger.debug "Calling general message handler for: '#{content}'"
405
- handler.call(message)
406
- end
407
- end
408
-
409
- def send_message(channel_id, text: nil, embeds: nil, masquerade_name: nil, masquerade_avatar_url: nil)
410
- if text.nil? && (embeds.nil? || embeds.empty?)
411
- @logger.debug "AN ERROR HAS OCCURED: Cannot send empty message or embeds."
412
- return
413
- end
414
-
415
- payload = {}
416
- payload[:content] = text if text
417
-
418
- if embeds && !embeds.empty?
419
- filtered_embeds = embeds.map do |embed|
420
- supported_keys = ['title', 'description', 'colour', 'url', 'icon_url', 'media']
421
- embed.select { |k, v| supported_keys.include?(k.to_s) }
422
- end
423
- payload[:embeds] = filtered_embeds
424
- end
425
- if masquerade_name || masquerade_avatar_url
426
- payload[:masquerade] = {}
427
- payload[:masquerade][:name] = masquerade_name if masquerade_name
428
- payload[:masquerade][:avatar] = masquerade_avatar_url if masquerade_avatar_url
429
- end
430
-
431
- @request_queue.enqueue do
432
- @logger.debug "Attempting to send message to channel '#{channel_id}' via queue..."
433
- uri = URI("#{@api_url}/channels/#{channel_id}/messages")
434
- req = Net::HTTP::Post.new(uri)
435
- _add_auth_header(req)
436
- req['Content-Type'] = 'application/json'
437
- req.body = payload.to_json
438
-
439
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
440
- http.request(req)
441
- end
442
-
443
- if res.is_a?(Net::HTTPSuccess)
444
- @logger.debug "Message sent successfully!"
445
- else
446
- @logger.debug "AN ERROR HAS OCCURED: Failed to send message: #{res.message} (Code: #{res.code})"
447
- @logger.debug "Response Body: #{res.body}"
448
- end
449
- end
450
- rescue => e
451
- @logger.debug "AN ERROR HAS OCCURED: Error enqueuing message: #{e.message}"
452
- @logger.debug e.backtrace.join("\n")
453
- end
454
-
455
- def find_unicode_emoji(shortcode)
456
- EMOJI_MAP[shortcode]
457
- end
458
-
459
- def add_reaction(channel_id, message_id, emoji_id)
460
- if emoji_id.start_with?(':') && emoji_id.end_with?(':')
461
- @logger.debug "AN ERROR HAS OCCURED: Cannot add reaction with shortcode '#{emoji_id}'. Please use the actual Unicode emoji or a custom emoji ID."
462
- return
463
- end
464
-
465
- @request_queue.enqueue do
466
- @logger.debug "Attempting to add reaction '#{emoji_id}' to message '#{message_id}' in channel '#{channel_id}' via queue."
467
- encoded_emoji_id = CGI.escape(emoji_id)
468
-
469
- uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}")
470
- req = Net::HTTP::Put.new(uri)
471
- _add_auth_header(req)
472
-
473
- res = nil
474
- begin
475
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10, open_timeout: 10) do |http|
476
- http.request(req)
477
- end
478
- rescue Net::ReadTimeout => e
479
- @logger.debug "AN ERROR HAS OCCURED: The network request timed out while waiting for a response after 10 seconds. Error: #{e.message}"
480
- return
481
- rescue Net::OpenTimeout => e
482
- @logger.debug "AN ERROR HAS OCCURED: The network request timed out while trying to open a connection after 10 seconds. Error: #{e.message}"
483
- return
484
- rescue => e
485
- @logger.debug "AN ERROR HAS OCCURED: An unexpected error occurred during the network request. Error: #{e.message}"
486
- return
487
- ensure
488
- @logger.debug "Network request to add reaction finished."
489
- end
490
-
491
- @logger.debug "Received response with code: #{res.code}"
492
- if res.is_a?(Net::HTTPSuccess) || res.code == '204'
493
- @logger.debug "Reaction added successfully!"
494
- else
495
- @logger.debug "AN ERROR HAS OCCURED: Failed to add reaction: #{res.message} (Code: #{res.code})"
496
- @logger.debug "Response Body: #{res.body}"
497
- if res.code == '403'
498
- @logger.debug "HINT: The bot may be missing the 'AddReactions' permission in this channel or server."
499
- end
500
- end
501
- end
502
- end
503
-
504
- def remove_reaction(channel_id, message_id, emoji_id, user_id: nil)
505
- target_user_id = user_id || @user_id
506
- @request_queue.enqueue do
507
- @logger.debug "Attempting to remove reaction '#{emoji_id}' from message '#{message_id}' by user '#{target_user_id}' in channel '#{channel_id}' via queue."
508
-
509
- encoded_emoji_id = CGI.escape(emoji_id)
510
-
511
- uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}?user_id=#{target_user_id}")
512
- req = Net::HTTP::Delete.new(uri)
513
- _add_auth_header(req)
514
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
515
- http.request(req)
516
- end
517
- if res.is_a?(Net::HTTPSuccess) || res.code == '204'
518
- @logger.debug "Reaction removed successfully!"
519
- else
520
- @logger.debug "AN ERROR HAS OCCURED: Failed to remove reaction: #{res.message} (Code: #{res.code})"
521
- @logger.debug "Response Body: #{res.body}"
522
- end
523
- end
524
- rescue => e
525
- @logger.debug "AN ERROR HAS OCCURED: Error enqueuing remove reaction: #{e.message}"
526
- @logger.debug e.backtrace.join("\n")
527
- end
528
-
529
- def get_channel_details(channel_id)
530
- @logger.debug "Fetching channel details for ID: #{channel_id} (direct API call for permission check)."
531
- uri = URI("#{@api_url}/channels/#{channel_id}")
532
- req = Net::HTTP::Get.new(uri)
533
- req['x-bot-token'] = @token
534
-
535
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
536
- http.request(req)
537
- end
538
-
539
- if res.is_a?(Net::HTTPSuccess)
540
- JSON.parse(res.body)
541
- else
542
- @logger.debug "AN ERROR HAS OCCURED: Failed to fetch channel details for #{channel_id}: #{res.message} (Code: #{res.code})"
543
- nil
544
- end
545
- rescue => e
546
- @logger.debug "AN ERROR HAS OCCURED: Error fetching channel details: #{e.message}"
547
- nil
548
- end
549
-
550
- def get_server_info(server_id)
551
- @logger.debug "get_server_info called with server_id: '#{server_id}' (Type: #{server_id.class}, Length: #{server_id.length})"
552
- @logger.debug "Available server IDs in cache (@servers.keys): #{@servers.keys.inspect}"
553
- found_server = @servers[server_id]
554
- @logger.debug "Result of @servers[server_id]: #{found_server.inspect}"
555
- found_server
556
- end
557
- def get_server_name(server_id)
558
- @servers[server_id]&.[]('name')
559
- end
560
-
561
- def stop
562
- @logger.debug "Stopping bot..."
563
- @running = false
564
- if @websocket_thread && @websocket_thread.alive?
565
- unless @websocket_thread.join(5)
566
- @logger.debug "WebSocket thread did not terminate gracefully, forcing kill."
567
- @websocket_thread.kill
568
- end
569
- @logger.debug "WebSocket thread terminated."
570
- end
571
- if @websocket && @websocket.open?
572
- @websocket.close
573
- @logger.debug "WebSocket closed."
574
- end
575
- @request_queue.stop_processing
576
- @logger.debug "Bot stopped."
577
- end
578
-
579
- private
580
-
581
- def _add_auth_header(request)
582
- if @selfbot
583
- request['x-session-token'] = @token
584
- else
585
- request['x-bot-token'] = @token
586
- end
587
- end
588
- end
1
+ # lib/stoatrb/bot.rb
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'websocket-client-simple'
5
+ require 'thread'
6
+ require 'time'
7
+ require_relative 'debuglogger'
8
+ require_relative 'request_queue'
9
+ require_relative 'webhooks'
10
+
11
+ module Stoatrb
12
+ class StoatBot
13
+ EMOJI_MAP = {
14
+ ':grinning:' => '😀',
15
+ ':heart:' => '❤️',
16
+ ':joy:' => '😂',
17
+ ':unamused:' => '😒',
18
+ ':sunglasses:' => '😎',
19
+ ':thinking:' => '🤔',
20
+ ':clap:' => '👏',
21
+ ':thumbsup:' => '👍',
22
+ ':thumbsdown:' => '👎',
23
+ ':point_up:' => '☝️',
24
+ ':+1:' => '👍',
25
+ ':-1:' => '👎'
26
+ }.freeze
27
+ attr_reader :token, :user_id, :bot_name, :servers, :prefix, :bot_owner_id, :bot_discriminator, :bot_discoverable, :bot_creation_date, :webhooks
28
+ attr_accessor :websocket_url, :api_url, :cdn_url
29
+ # Initializes the bot with the provided token and configuration.
30
+ def initialize(token, api_url: 'https://api.stoat.chat/0.8', websocket_url: 'wss://events.stoat.chat', cdn_url: 'https://cdn.stoatusercontent.com', prefix: nil, debuglogs: false, selfbot: false)
31
+ @token = token
32
+ @api_url = api_url
33
+ @websocket_url = websocket_url
34
+ @cdn_url = cdn_url
35
+
36
+ @user_id = nil
37
+ @bot_name = nil
38
+ @servers = {}
39
+ @commands = {}
40
+ @message_handlers = []
41
+
42
+ @websocket = nil
43
+ @websocket_thread = nil
44
+ @heartbeat_interval = 30
45
+ @last_heartbeat_sent = Time.now.to_i
46
+ @running = false
47
+ @ready_event_received = false
48
+ @logger = Stoatrb::DebugLogger.new(debuglogs)
49
+ @request_queue = Stoatrb::RequestQueue.new(500)
50
+ @webhooks = Stoatrb::Webhooks.new(api_url, @logger, token, selfbot)
51
+
52
+ @prefix = "!"
53
+ @prefix = prefix if prefix
54
+ @selfbot = selfbot
55
+ @logger.debug "Stoat.chat Bot initialized. API: #{@api_url}, WS: #{@websocket_url}, CDN: #{@cdn_url}, Prefix: #{@prefix}"
56
+ end
57
+
58
+ def get_botinfo
59
+ {
60
+ 'bot_id' => @user_id,
61
+ 'bot_name' => @bot_name,
62
+ 'bot_discriminator' => @bot_discriminator,
63
+ 'bot_ownerid' => @bot_owner_id,
64
+ 'bot_creationdate' => @bot_creation_date,
65
+ 'bot_flags' => @bot_flags,
66
+ 'bot_discoverable' => @bot_discoverable,
67
+ 'bot_public' => @bot_public,
68
+ 'bot_analytics' => @bot_analytics,
69
+ 'bot_prefix' => @prefix,
70
+ 'bot_token' => @token
71
+ }
72
+ end
73
+
74
+ # Log into the Stoat.chat bot
75
+ def login
76
+ @logger.debug "BOT: Bot attempting to start...."
77
+ # Step 1: Fetch initial bot user details via REST API using /users/@me with X-Bot-Token | As confirmed, /users/@me returns the bot's user object directly.
78
+ uri = URI("#{@api_url}/users/@me")
79
+ req = Net::HTTP::Get.new(uri)
80
+ _add_auth_header(req)
81
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
82
+ http.request(req)
83
+ end
84
+
85
+ if res.is_a?(Net::HTTPSuccess)
86
+ bot_user_data = JSON.parse(res.body)
87
+ @logger.debug "Response Body (successful login attempt from /users/@me): #{res.body}"
88
+ @user_id = bot_user_data&.[]('_id')
89
+ @bot_name = bot_user_data&.[]('username')
90
+ @bot_discriminator = bot_user_data&.[]('discriminator')
91
+ bot_specific_info = bot_user_data&.[]('bot')
92
+ @bot_owner_id = bot_specific_info&.[]('owner')
93
+ @bot_flags = bot_specific_info&.[]('flags')
94
+ @bot_discoverable = nil
95
+ @bot_public = nil
96
+ @bot_analytics = nil
97
+ @bot_creation_date = Time.at(bot_user_data&.[]('created_at').to_i / 1000) rescue nil
98
+ if @user_id.nil? || @bot_name.nil?
99
+ @logger.debug "AN ERROR HAS OCCURED: Essential properties (_id, username) missing or nil in API response from /users/@me. Please inspect the 'Response Body' above for unexpected format."
100
+ return false
101
+ end
102
+
103
+ @logger.debug "Successfully identified as #{@bot_name} (ID: #{@user_id}) via REST API."
104
+ @logger.debug "Bot Owner ID: #{@bot_owner_id}, Owner ID: #{@bot_owner_id}, Discoverable: #{@bot_discoverable.inspect}, Created: #{@bot_creation_date}"
105
+ else
106
+ @logger.debug "AN ERROR HAS OCCURED: Initial REST API call failed.... #{res.message} (Code: #{res.code})"
107
+ @logger.debug "Please check your bot token. Cannot proceed with WebSocket connection. Response Body: #{res.body}"
108
+ return false
109
+ end
110
+ # Step 2: Connect to the WebSocket
111
+ @running = true
112
+ connect_websocket
113
+ @request_queue.start_processing
114
+ true
115
+ end
116
+ def run
117
+ begin
118
+ unless login
119
+ puts "Bot failed to log in. Exiting."
120
+ exit(1)
121
+ end
122
+ puts "Bot is online and running. Press Ctrl+C to stop."
123
+ @websocket_thread.join
124
+ rescue Interrupt
125
+ puts "\nCtrl+C detected. Shutting down bot gracefully..."
126
+ rescue => e
127
+ puts "An unhandled error occurred in the main script loop: #{e.message}"
128
+ puts e.backtrace.join("\n")
129
+ ensure
130
+ stop
131
+ puts "Bot process ended."
132
+ end
133
+ end
134
+
135
+ def set_presence(status)
136
+ valid_statuses = ['Online', 'Idle', 'Dnd', 'Focus', 'Invisible']
137
+ unless valid_statuses.include?(status)
138
+ @logger.debug "AN ERROR HAS OCCURED: Invalid status '#{status}'. Must be one of the following choices: #{valid_statuses.join(', ')}."
139
+ return false
140
+ end
141
+
142
+ @logger.debug "Attempting to set bot presence to '#{status}'."
143
+ uri = URI("#{@api_url}/users/@me")
144
+ req = Net::HTTP::Patch.new(uri)
145
+ _add_auth_header(req)
146
+ req['Content-Type'] = 'application/json'
147
+ payload = {
148
+ status: {
149
+ presence: status
150
+ }
151
+ }
152
+ req.body = payload.to_json
153
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
154
+ http.request(req)
155
+ end
156
+
157
+ if res.is_a?(Net::HTTPSuccess)
158
+ @logger.debug "BOT: Presence has been changed to '#{status}'."
159
+ true
160
+ else
161
+ @logger.debug "AN ERROR HAS OCCURED: Failed to update presence. Response: #{res.message} (Code: #{res.code})"
162
+ @logger.debug "Response Body: #{res.body}"
163
+ false
164
+ end
165
+ rescue => e
166
+ @logger.debug "AN ERROR HAS OCCURED: Error setting presence: #{e.message}"
167
+ @logger.debug e.backtrace.join("\n")
168
+ false
169
+ end
170
+
171
+ def connect_websocket
172
+ if @websocket && @websocket.open? && @running
173
+ @logger.debug "WebSocket already open and running."
174
+ return
175
+ end
176
+ @logger.debug "Connecting to WebSocket: #{@websocket_url}"
177
+ ws_url_with_token = "#{@websocket_url}?token=#{@token}"
178
+ bot_instance = self
179
+ thread_logger = @logger
180
+
181
+ @websocket_thread = Thread.new do
182
+ begin
183
+ @websocket = WebSocket::Client::Simple.connect ws_url_with_token
184
+ @websocket.on :open do
185
+ thread_logger.debug "WebSocket connection opened!"
186
+ end
187
+ @websocket.on :message do |msg|
188
+ bot_instance.handle_websocket_message(msg.data)
189
+ end
190
+ @websocket.on :close do |e|
191
+ close_code = e&.code || 'N/A'
192
+ close_reason = e&.reason || 'No reason provided'
193
+ thread_logger.debug "WebSocket closed: #{close_code} - #{close_reason}."
194
+ bot_instance.instance_variable_set(:@websocket, nil)
195
+ if bot_instance.instance_variable_get(:@running)
196
+ thread_logger.debug "Attempting to reconnect in 5 seconds..."
197
+ sleep 5
198
+ bot_instance.connect_websocket
199
+ else
200
+ thread_logger.debug "BOT: Bot has stopped and will not try to reconnect" # Use local thread_logger
201
+ end
202
+ end
203
+ @websocket.on :error do |e|
204
+ error_message = e&.message || 'Unknown error'
205
+ thread_logger.debug "WebSocket error: #{error_message}"
206
+ @websocket.close if @websocket&.open?
207
+ end
208
+
209
+ while bot_instance.instance_variable_get(:@running)
210
+ if Time.now.to_i - @last_heartbeat_sent > @heartbeat_interval
211
+ bot_instance.send_heartbeat
212
+ end
213
+ sleep 1
214
+ end
215
+ thread_logger.debug "WebSocket thread loop finished."
216
+ rescue => e
217
+ thread_logger.debug "WebSocket thread unhandled exception: #{e.message}"
218
+ thread_logger.debug e.backtrace.join("\n")
219
+ bot_instance.instance_variable_set(:@websocket, nil)
220
+ if bot_instance.instance_variable_get(:@running)
221
+ thread_logger.debug "Attempting to reconnect in 5 seconds due to unhandled error..."
222
+ sleep 5
223
+ bot_instance.connect_websocket
224
+ else
225
+ thread_logger.debug "Bot is stopped, not attempting to reconnect after unhandled error."
226
+ end
227
+ end
228
+ end
229
+ sleep 1
230
+ end
231
+
232
+ def send_heartbeat
233
+ if @websocket && @websocket.open?
234
+ payload = { type: 'Ping', data: Time.now.to_i }
235
+ @websocket.send(payload.to_json)
236
+ @last_heartbeat_sent = Time.now.to_i
237
+ end
238
+ rescue OpenSSL::SSL::SSLError => e
239
+ @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat (SSL): #{e.message}"
240
+ @websocket&.close
241
+ rescue => e
242
+ @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat: #{e.message}"
243
+ @websocket&.close
244
+ end
245
+ def handle_websocket_message(raw_data)
246
+ begin
247
+ event = JSON.parse(raw_data)
248
+ event_type = event['type']
249
+ case event_type
250
+ when 'Ready'
251
+ @logger.debug "Received 'Ready' event. Populating initial data..."
252
+ if event['servers']
253
+ event['servers'].each do |server_data|
254
+ @servers[server_data['_id']] = {
255
+ 'name' => server_data['name'],
256
+ 'id' => server_data['_id']
257
+ }
258
+ @logger.debug "Stored server ID from Ready event: #{server_data['_id'].inspect}" # New debug
259
+ end
260
+ @logger.debug "Loaded #{event['servers'].count} real servers from 'Ready' event."
261
+ else
262
+ @logger.debug "'Ready' event received but no 'servers' array found."
263
+ end
264
+ @ready_event_received = true
265
+ @logger.debug "@ready_event_received set to true."
266
+ when 'Message'
267
+ unless event['author'] == @user_id
268
+ process_message(event)
269
+ end
270
+ when 'Authenticated'
271
+ @logger.debug "Successfully authenticated with WebSocket."
272
+ when 'Pong'
273
+ # @logger.debug "Received Pong response."
274
+ when 'Error'
275
+ @logger.debug "AN ERROR HAS OCCURED: Stoat.chat API Error received via WebSocket: #{event['error']}"
276
+ else
277
+ # @logger.debug "AN ERROR HAS OCCURED: Unhandled WebSocket event type: #{event_type}"
278
+ end
279
+ rescue JSON::ParserError => e
280
+ @logger.debug "AN ERROR HAS OCCURED: Failed to parse WebSocket message as JSON: #{e.message}"
281
+ @logger.debug "Raw message: #{raw_data}"
282
+ rescue => e
283
+ @logger.debug "AN ERROR HAS OCCURED: Error processing WebSocket message: #{e.message}"
284
+ @logger.debug e.backtrace.join("\n")
285
+ end
286
+ end
287
+
288
+ def on_message(&block)
289
+ @message_handlers << block
290
+ end
291
+ def command(command_name, required_permissions: [], nsfw_channel_required: false, &block)
292
+ cmd_key = command_name.to_s.downcase
293
+ @commands[cmd_key] = {
294
+ 'block' => block,
295
+ 'permissions' => required_permissions,
296
+ 'nsfw_cmd' => nsfw_channel_required
297
+ }
298
+ @logger.debug "Command '#{command_name}' registered! Permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
299
+ end
300
+
301
+ def check_permissions(message, required_permissions, nsfw_channel_required)
302
+ @logger.debug "check_permissions called with required_permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
303
+ @logger.debug "message['channel'] value: #{message['channel'].inspect}"
304
+ server_id_from_message = message['member']&.[]('_id')&.[]('server')
305
+ @logger.debug "message['member']['_id']['server'] value: #{server_id_from_message.inspect}"
306
+ if required_permissions.empty? && !nsfw_channel_required
307
+ @logger.debug "Permission Check: No specific permissions or NSFW requirement. Allowing command."
308
+ return true
309
+ end
310
+ user_id = message['author']
311
+
312
+ # --- PERMISSION CHECK SYSTEM ---
313
+ # --- BotOwner ---
314
+ if required_permissions.include?('BotOwner')
315
+ if user_id == @bot_owner_id
316
+ @logger.debug "Permission Check: User is the bot owner. Allowing command."
317
+ return true
318
+ else
319
+ @logger.debug "Permission Check: User is NOT the bot owner. Denying command."
320
+ return false
321
+ end
322
+ end
323
+ # --- ServerOwner ---
324
+ if required_permissions.include?('ServerOwner')
325
+ server_id = message['member']&.[]('_id')&.[]('server')
326
+
327
+ if server_id.nil?
328
+ @logger.debug "Permission Check: Command requires ServerOwner, but message is not in a server (DM). Denying command."
329
+ return false
330
+ end
331
+
332
+ owner_id = get_server_owner_id(server_id)
333
+ if owner_id && user_id == owner_id
334
+ @logger.debug "Permission Check: User is the server owner (#{owner_id}). Allowing command."
335
+ return true
336
+ else
337
+ @logger.debug "Permission Check: User is NOT the server owner (#{owner_id.inspect}). Denying command."
338
+ return false
339
+ end
340
+ end
341
+ # --- END PERMISSION CHECK SYSTEM ---
342
+
343
+ channel_id = message['channel']
344
+ if server_id_from_message.nil?
345
+ if nsfw_channel_required
346
+ @logger.debug "Permission Check: Command requires NSFW channel, but message is in DM. Denying command."
347
+ return false
348
+ end
349
+ else
350
+ channel_details = get_channel_details(channel_id)
351
+ if channel_details.nil?
352
+ @logger.debug "Permission Check: Command requires NSFW channel but could not retrieve channel details. Denying command."
353
+ return false
354
+ end
355
+ is_channel_nsfw = channel_details['nsfw'] || false
356
+ if nsfw_channel_required && !is_channel_nsfw
357
+ @logger.debug "Permission Check: Command requires NSFW channel, but current channel is NOT NSFW marked. Denying."
358
+ return false
359
+ elsif !nsfw_channel_required && is_channel_nsfw
360
+ @logger.debug "Permission Check: Command does not require NSFW channel, but is in NSFW marked channel. Allowing."
361
+ end
362
+ end
363
+
364
+ if !required_permissions.empty?
365
+ @logger.debug "User #{user_id} is not the bot owner. Requires permissions: #{required_permissions.inspect}. (Full permission check for non-owner, server-specific permissions not implemented)."
366
+ return false
367
+ end
368
+ @logger.debug "Permission Check: All checks passed. Allowing command."
369
+ true
370
+ end
371
+
372
+ def edit_message(channel_id, message_id, text: nil, embeds: nil)
373
+ if text.nil? && (embeds.nil? || embeds.empty?)
374
+ @logger.debug "AN ERROR HAS OCCURED: Cannot edit message with empty content or embeds."
375
+ return
376
+ end
377
+
378
+ payload = {}
379
+ payload[:content] = text if text
380
+
381
+ if embeds && !embeds.empty?
382
+ filtered_embeds = embeds.map do |embed|
383
+ supported_keys = ['title', 'description', 'colour', 'url', 'icon_url', 'media']
384
+ embed.select { |k, v| supported_keys.include?(k.to_s) }
385
+ end
386
+ payload[:embeds] = filtered_embeds
387
+ end
388
+
389
+ @request_queue.enqueue do
390
+ @logger.debug "Attempting to edit message '#{message_id}' in channel '#{channel_id}' via queue..."
391
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}")
392
+ req = Net::HTTP::Patch.new(uri)
393
+ _add_auth_header(req)
394
+ req['Content-Type'] = 'application/json'
395
+ req.body = payload.to_json
396
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
397
+ http.request(req)
398
+ end
399
+ if res.is_a?(Net::HTTPSuccess)
400
+ @logger.debug "Message edited successfully!"
401
+ else
402
+ @logger.debug "AN ERROR HAS OCCURED: Failed to edit message: #{res.message} (Code: #{res.code})"
403
+ @logger.debug "Response Body: #{res.body}"
404
+ end
405
+ end
406
+ rescue => e
407
+ @logger.debug "AN ERROR HAS OCCURED: Error enqueuing message edit: #{e.message}"
408
+ @logger.debug e.backtrace.join("\n")
409
+ end
410
+
411
+ def process_message(message)
412
+ unless @ready_event_received
413
+ @logger.debug "AN ERROR HAS OCCURED: Bot is not ready for commands"
414
+ return
415
+ end
416
+ unless @selfbot
417
+ return if message['author'] == @user_id
418
+ end
419
+ content = message['content']&.strip
420
+ return if content.nil? || content.empty?
421
+ @logger.debug "Full Message object received in process_message: #{message.inspect}"
422
+
423
+ @commands.each do |cmd_name, cmd_data|
424
+ command_full_string = "#{@prefix}#{cmd_name}"
425
+ if content.downcase.start_with?(command_full_string.downcase)
426
+ args_string = content[command_full_string.length..]&.strip
427
+ args = args_string.to_s.split(/\s+/)
428
+ args = [] if args == ['']
429
+ # --- Permission Check (Pass both permissions and nsfw_channel_required to check_permissions) ---
430
+ if check_permissions(message, cmd_data['permissions'], cmd_data['nsfw_cmd'])
431
+ @logger.debug "Executing command: '#{cmd_name}' with args: #{args.inspect}"
432
+ cmd_data['block'].call(message, args)
433
+ else
434
+ channel_id = message['channel']
435
+ permission_denial = "⛔ You don't have permission to use the `#{cmd_name}` command."
436
+ if cmd_data['nsfw_cmd'] && message['member']
437
+ channel_details = get_channel_details(channel_id)
438
+ if channel_details && !channel_details['nsfw']
439
+ permission_denial = "⛔ A NSFW marked channel is required to use the following command: `#{cmd_name}`"
440
+ end
441
+ end
442
+
443
+ self.send_message(channel_id, text: permission_denial)
444
+ end
445
+ return
446
+ end
447
+ end
448
+
449
+ @message_handlers.each do |handler|
450
+ @logger.debug "Calling general message handler for: '#{content}'"
451
+ handler.call(message)
452
+ end
453
+ end
454
+
455
+ def send_message(channel_id, text: nil, embeds: nil, masquerade_name: nil, masquerade_avatar_url: nil)
456
+ if text.nil? && (embeds.nil? || embeds.empty?)
457
+ @logger.debug "AN ERROR HAS OCCURED: Cannot send empty message or embeds."
458
+ return
459
+ end
460
+
461
+ payload = {}
462
+ payload[:content] = text if text
463
+
464
+ if embeds && !embeds.empty?
465
+ filtered_embeds = embeds.map do |embed|
466
+ supported_keys = ['title', 'description', 'colour', 'url', 'icon_url', 'media']
467
+ embed.select { |k, v| supported_keys.include?(k.to_s) }
468
+ end
469
+ payload[:embeds] = filtered_embeds
470
+ end
471
+ if masquerade_name || masquerade_avatar_url
472
+ payload[:masquerade] = {}
473
+ payload[:masquerade][:name] = masquerade_name if masquerade_name
474
+ payload[:masquerade][:avatar] = masquerade_avatar_url if masquerade_avatar_url
475
+ end
476
+
477
+ @request_queue.enqueue do
478
+ @logger.debug "Attempting to send message to channel '#{channel_id}' via queue..."
479
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages")
480
+ req = Net::HTTP::Post.new(uri)
481
+ _add_auth_header(req)
482
+ req['Content-Type'] = 'application/json'
483
+ req.body = payload.to_json
484
+
485
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
486
+ http.request(req)
487
+ end
488
+
489
+ if res.is_a?(Net::HTTPSuccess)
490
+ @logger.debug "Message sent successfully!"
491
+ else
492
+ @logger.debug "AN ERROR HAS OCCURED: Failed to send message: #{res.message} (Code: #{res.code})"
493
+ @logger.debug "Response Body: #{res.body}"
494
+ end
495
+ end
496
+ rescue => e
497
+ @logger.debug "AN ERROR HAS OCCURED: Error enqueuing message: #{e.message}"
498
+ @logger.debug e.backtrace.join("\n")
499
+ end
500
+ def send_message_sync(channel_id, text: nil)
501
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages")
502
+ req = Net::HTTP::Post.new(uri)
503
+ _add_auth_header(req)
504
+ req['Content-Type'] = 'application/json'
505
+ payload = { content: text }
506
+ req.body = payload.to_json
507
+ @logger.debug "DEBUG: Sync POST URL: #{uri}"
508
+ @logger.debug "DEBUG: Sync POST Payload: #{req.body}"
509
+ begin
510
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10) do |http|
511
+ http.request(req)
512
+ end
513
+ if res.is_a?(Net::HTTPSuccess)
514
+ response_data = JSON.parse(res.body)
515
+ @logger.debug "Sync message sent successfully. ID: #{response_data['_id']}"
516
+ return response_data['_id']
517
+ else
518
+ @logger.debug "ERROR: Sync send failed. Code: #{res.code}. Response: #{res.body.slice(0, 150)}..."
519
+ return nil
520
+ end
521
+ rescue => e
522
+ @logger.debug "ERROR: Synchronous HTTP request failed: #{e.message}"
523
+ return nil
524
+ end
525
+ end
526
+
527
+ def find_unicode_emoji(shortcode)
528
+ EMOJI_MAP[shortcode]
529
+ end
530
+ def add_reaction(channel_id, message_id, emoji_id)
531
+ if emoji_id.start_with?(':') && emoji_id.end_with?(':')
532
+ @logger.debug "AN ERROR HAS OCCURED: Cannot add reaction with shortcode '#{emoji_id}'. Please use the actual Unicode emoji or a custom emoji ID."
533
+ return
534
+ end
535
+
536
+ @request_queue.enqueue do
537
+ @logger.debug "Attempting to add reaction '#{emoji_id}' to message '#{message_id}' in channel '#{channel_id}' via queue."
538
+ encoded_emoji_id = CGI.escape(emoji_id)
539
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}")
540
+ req = Net::HTTP::Put.new(uri)
541
+ _add_auth_header(req)
542
+
543
+ res = nil
544
+ begin
545
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10, open_timeout: 10) do |http|
546
+ http.request(req)
547
+ end
548
+ rescue Net::ReadTimeout => e
549
+ @logger.debug "AN ERROR HAS OCCURED: The network request timed out while waiting for a response after 10 seconds. Error: #{e.message}"
550
+ return
551
+ rescue Net::OpenTimeout => e
552
+ @logger.debug "AN ERROR HAS OCCURED: The network request timed out while trying to open a connection after 10 seconds. Error: #{e.message}"
553
+ return
554
+ rescue => e
555
+ @logger.debug "AN ERROR HAS OCCURED: An unexpected error occurred during the network request. Error: #{e.message}"
556
+ return
557
+ ensure
558
+ @logger.debug "Network request to add reaction finished."
559
+ end
560
+ @logger.debug "Received response with code: #{res.code}"
561
+ if res.is_a?(Net::HTTPSuccess) || res.code == '204'
562
+ @logger.debug "Reaction added successfully!"
563
+ else
564
+ @logger.debug "AN ERROR HAS OCCURED: Failed to add reaction: #{res.message} (Code: #{res.code})"
565
+ @logger.debug "Response Body: #{res.body}"
566
+ if res.code == '403'
567
+ @logger.debug "HINT: The bot may be missing the 'AddReactions' permission in this channel or server."
568
+ end
569
+ end
570
+ end
571
+ end
572
+ def remove_reaction(channel_id, message_id, emoji_id, user_id: nil)
573
+ target_user_id = user_id || @user_id
574
+ @request_queue.enqueue do
575
+ @logger.debug "Attempting to remove reaction '#{emoji_id}' from message '#{message_id}' by user '#{target_user_id}' in channel '#{channel_id}' via queue."
576
+ encoded_emoji_id = CGI.escape(emoji_id)
577
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}?user_id=#{target_user_id}")
578
+ req = Net::HTTP::Delete.new(uri)
579
+ _add_auth_header(req)
580
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
581
+ http.request(req)
582
+ end
583
+ if res.is_a?(Net::HTTPSuccess) || res.code == '204'
584
+ @logger.debug "Reaction removed successfully!"
585
+ else
586
+ @logger.debug "AN ERROR HAS OCCURED: Failed to remove reaction: #{res.message} (Code: #{res.code})"
587
+ @logger.debug "Response Body: #{res.body}"
588
+ end
589
+ end
590
+ rescue => e
591
+ @logger.debug "AN ERROR HAS OCCURED: Error enqueuing remove reaction: #{e.message}"
592
+ @logger.debug e.backtrace.join("\n")
593
+ end
594
+
595
+ def get_server_owner_id(server_id)
596
+ return nil unless server_id
597
+ @logger.debug "Fetching owner ID for server #{server_id}."
598
+ uri = URI("#{@api_url}/servers/#{server_id}")
599
+ req = Net::HTTP::Get.new(uri)
600
+ _add_auth_header(req)
601
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
602
+ http.request(req)
603
+ end
604
+ if res.is_a?(Net::HTTPSuccess)
605
+ server_data = JSON.parse(res.body)
606
+ owner_id = server_data&.[]('owner')
607
+ @logger.debug "Successfully retrieved owner ID: #{owner_id} for server #{server_id}."
608
+ owner_id
609
+ else
610
+ @logger.debug "AN ERROR HAS OCCURED: Failed to fetch server details for #{server_id}: #{res.message} (Code: #{res.code})"
611
+ nil
612
+ end
613
+ rescue => e
614
+ @logger.debug "AN ERROR HAS OCCURED: Error fetching server owner ID: #{e.message}"
615
+ nil
616
+ end
617
+
618
+ def get_channel_details(channel_id)
619
+ @logger.debug "Fetching channel details for ID: #{channel_id} (direct API call for permission check)."
620
+ uri = URI("#{@api_url}/channels/#{channel_id}")
621
+ req = Net::HTTP::Get.new(uri)
622
+ req['x-bot-token'] = @token
623
+
624
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
625
+ http.request(req)
626
+ end
627
+
628
+ if res.is_a?(Net::HTTPSuccess)
629
+ JSON.parse(res.body)
630
+ else
631
+ @logger.debug "AN ERROR HAS OCCURED: Failed to fetch channel details for #{channel_id}: #{res.message} (Code: #{res.code})"
632
+ nil
633
+ end
634
+ rescue => e
635
+ @logger.debug "AN ERROR HAS OCCURED: Error fetching channel details: #{e.message}"
636
+ nil
637
+ end
638
+
639
+ def get_server_info(server_id)
640
+ @logger.debug "get_server_info called with server_id: '#{server_id}' (Type: #{server_id.class}, Length: #{server_id.length})"
641
+ @logger.debug "Available server IDs in cache (@servers.keys): #{@servers.keys.inspect}"
642
+ found_server = @servers[server_id]
643
+ @logger.debug "Result of @servers[server_id]: #{found_server.inspect}"
644
+ found_server
645
+ end
646
+ def get_server_name(server_id)
647
+ @servers[server_id]&.[]('name')
648
+ end
649
+
650
+ def stop
651
+ @logger.debug "Stopping bot..."
652
+ @running = false
653
+ if @websocket_thread && @websocket_thread.alive?
654
+ unless @websocket_thread.join(5)
655
+ @logger.debug "WebSocket thread did not terminate gracefully, forcing kill."
656
+ @websocket_thread.kill
657
+ end
658
+ @logger.debug "WebSocket thread terminated."
659
+ end
660
+ if @websocket && @websocket.open?
661
+ @websocket.close
662
+ @logger.debug "WebSocket closed."
663
+ end
664
+ @request_queue.stop_processing
665
+ @logger.debug "Bot stopped."
666
+ end
667
+
668
+ private
669
+
670
+ def _add_auth_header(request)
671
+ if @selfbot
672
+ request['x-session-token'] = @token
673
+ else
674
+ request['x-bot-token'] = @token
675
+ end
676
+ end
677
+ end
589
678
  end