revoltrb 0.0.2 → 0.0.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.
data/lib/revoltrb/bot.rb CHANGED
@@ -1,538 +1,539 @@
1
- # lib/revoltrb/bot.rb
2
-
3
- require 'json'
4
- require 'net/http'
5
- require 'websocket-client-simple' # This is required for WebSocket communication
6
- require 'thread'
7
- require 'time'
8
- require_relative 'debuglogger'
9
- require_relative 'request_queue'
10
-
11
- module Revoltrb
12
- class RevoltBot
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
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.revolt.chat', websocket_url: 'wss://app.revolt.chat/events', cdn_url: 'https://cdn.revoltusercontent.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 = Revoltrb::DebugLogger.new(debuglogs)
50
- @request_queue = Revoltrb::RequestQueue.new(500)
51
-
52
- @prefix = "!"
53
- @prefix = prefix if prefix
54
- @selfbot = selfbot
55
- @logger.debug "RevoltBot 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 Revolt.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 "Error: Essential properties (_id, username) missing or nil in API response from /users/@me."
100
- @logger.debug "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 after parsing: #{@bot_owner_id}"
106
- @logger.debug "Owner ID: #{@bot_owner_id}, Discoverable: #{@bot_discoverable.inspect}, Created: #{@bot_creation_date}"
107
- else
108
- @logger.debug "Initial REST API call failed: #{res.message} (Code: #{res.code})"
109
- @logger.debug "Response Body: #{res.body}"
110
- @logger.debug "Please check your bot token. Cannot proceed with WebSocket connection."
111
- return false
112
- end
113
- # Step 2: Connect to the WebSocket
114
- @running = true
115
- connect_websocket
116
- @request_queue.start_processing
117
- true
118
- end
119
-
120
- def connect_websocket
121
- if @websocket && @websocket.open? && @running
122
- @logger.debug "WebSocket already open and running."
123
- return
124
- end
125
-
126
- @logger.debug "Connecting to WebSocket: #{@websocket_url}"
127
- ws_url_with_token = "#{@websocket_url}?token=#{@token}"
128
- bot_instance = self
129
- thread_logger = @logger
130
-
131
- @websocket_thread = Thread.new do
132
- begin
133
- @websocket = WebSocket::Client::Simple.connect ws_url_with_token
134
- @websocket.on :open do
135
- thread_logger.debug "WebSocket connection opened!"
136
- end
137
- @websocket.on :message do |msg|
138
- bot_instance.handle_websocket_message(msg.data)
139
- end
140
- @websocket.on :close do |e|
141
- close_code = e&.code || 'N/A'
142
- close_reason = e&.reason || 'No reason provided'
143
- thread_logger.debug "WebSocket closed: #{close_code} - #{close_reason}."
144
- bot_instance.instance_variable_set(:@websocket, nil)
145
- if bot_instance.instance_variable_get(:@running)
146
- thread_logger.debug "Attempting to reconnect in 5 seconds..."
147
- sleep 5
148
- bot_instance.connect_websocket
149
- else
150
- thread_logger.debug "BOT: Bot has stopped and will not try to reconnect" # Use local thread_logger
151
- end
152
- end
153
- @websocket.on :error do |e|
154
- error_message = e&.message || 'Unknown error'
155
- thread_logger.debug "WebSocket error: #{error_message}"
156
- @websocket.close if @websocket&.open?
157
- end
158
-
159
- while bot_instance.instance_variable_get(:@running)
160
- if Time.now.to_i - @last_heartbeat_sent > @heartbeat_interval
161
- bot_instance.send_heartbeat
162
- end
163
- sleep 1
164
- end
165
- thread_logger.debug "WebSocket thread loop finished."
166
- rescue => e
167
- thread_logger.debug "WebSocket thread unhandled exception: #{e.message}"
168
- thread_logger.debug e.backtrace.join("\n")
169
- bot_instance.instance_variable_set(:@websocket, nil)
170
- if bot_instance.instance_variable_get(:@running)
171
- thread_logger.debug "Attempting to reconnect in 5 seconds due to unhandled error..."
172
- sleep 5
173
- bot_instance.connect_websocket
174
- else
175
- thread_logger.debug "Bot is stopped, not attempting to reconnect after unhandled error."
176
- end
177
- end
178
- end
179
- sleep 1
180
- end
181
-
182
- def send_heartbeat
183
- if @websocket && @websocket.open?
184
- payload = { type: 'Ping', data: Time.now.to_i }
185
- @websocket.send(payload.to_json)
186
- @last_heartbeat_sent = Time.now.to_i
187
- end
188
- rescue OpenSSL::SSL::SSLError => e
189
- @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat (SSL): #{e.message}"
190
- @websocket&.close
191
- rescue => e
192
- @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat: #{e.message}"
193
- @websocket&.close
194
- end
195
-
196
- def handle_websocket_message(raw_data)
197
- begin
198
- event = JSON.parse(raw_data)
199
- event_type = event['type']
200
- case event_type
201
- when 'Ready'
202
- @logger.debug "Received 'Ready' event. Populating initial data..."
203
- if event['servers']
204
- event['servers'].each do |server_data|
205
- @servers[server_data['_id']] = {
206
- 'name' => server_data['name'],
207
- 'id' => server_data['_id']
208
- }
209
- @logger.debug "Stored server ID from Ready event: #{server_data['_id'].inspect}" # New debug
210
- end
211
- @logger.debug "Loaded #{event['servers'].count} real servers from 'Ready' event."
212
- else
213
- @logger.debug "'Ready' event received but no 'servers' array found."
214
- end
215
- @ready_event_received = true
216
- @logger.debug "@ready_event_received set to true."
217
- when 'Message'
218
- unless event['author'] == @user_id
219
- process_message(event)
220
- end
221
- when 'Authenticated'
222
- @logger.debug "Successfully authenticated with WebSocket."
223
- when 'Pong'
224
- # @logger.debug "Received Pong response."
225
- when 'Error'
226
- @logger.debug "AN ERROR HAS OCCURED: Revolt API Error received via WebSocket: #{event['error']}"
227
- else
228
- # @logger.debug "AN ERROR HAS OCCURED: Unhandled WebSocket event type: #{event_type}"
229
- end
230
- rescue JSON::ParserError => e
231
- @logger.debug "Failed to parse WebSocket message as JSON: #{e.message}"
232
- @logger.debug "Raw message: #{raw_data}"
233
- rescue => e
234
- @logger.debug "Error processing WebSocket message: #{e.message}"
235
- @logger.debug e.backtrace.join("\n")
236
- end
237
- end
238
-
239
- def on_message(&block)
240
- @message_handlers << block
241
- end
242
- def command(command_name, required_permissions: [], nsfw_channel_required: false, &block)
243
- cmd_key = command_name.to_s.downcase
244
- @commands[cmd_key] = {
245
- 'block' => block,
246
- 'permissions' => required_permissions,
247
- 'nsfw_cmd' => nsfw_channel_required
248
- }
249
- @logger.debug "Command '#{command_name}' registered with permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
250
- end
251
-
252
- def check_permissions(message, required_permissions, nsfw_channel_required)
253
- @logger.debug "check_permissions called with required_permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
254
- @logger.debug "message['channel'] value: #{message['channel'].inspect}"
255
-
256
- server_id_from_message = message['member']&.[]('_id')&.[]('server')
257
- @logger.debug "message['member']['_id']['server'] value: #{server_id_from_message.inspect}"
258
-
259
- if required_permissions.empty? && !nsfw_channel_required
260
- @logger.debug "Permission Check: No specific permissions or NSFW requirement. Allowing command."
261
- return true
262
- end
263
- user_id = message['author']
264
-
265
- @logger.debug "\n--- PERMISSION DEBUG START ---"
266
- @logger.debug "User ID from message (user_id): '#{user_id}' | Bot Owner ID stored (@bot_owner_id): '#{@bot_owner_id}'"
267
- @logger.debug "Are they equal? (user_id == @bot_owner_id): #{user_id == @bot_owner_id}"
268
- # @logger.debug "User ID char codes: #{user_id.each_char.map(&:ord).join(', ')}"
269
- # @logger.debug "Bot Owner ID char codes: #{@bot_owner_id.each_char.map(&:ord).join(', ')}"
270
- @logger.debug "--- PERMISSION DEBUG END ---\n"
271
- # --- BotOwner Check ---
272
- if required_permissions.include?('BotOwner')
273
- if user_id == @bot_owner_id
274
- @logger.debug "Permission Check: User is the bot owner. Allowing command."
275
- return true
276
- else
277
- @logger.debug "Permission Check: User is NOT the bot owner. Denying command."
278
- return false
279
- end
280
- end
281
- # --- NSFW Channel Check ---
282
- channel_id = message['channel']
283
- if server_id_from_message.nil?
284
- if nsfw_channel_required
285
- @logger.debug "Permission Check: Command requires NSFW channel, but message is in DM. Denying command."
286
- return false
287
- end
288
- else
289
- channel_details = get_channel_details(channel_id)
290
- if channel_details.nil?
291
- @logger.debug "Permission Check: Command requires NSFW channel but could not retrieve channel details. Denying command to be safe."
292
- return false
293
- end
294
- is_channel_nsfw = channel_details['nsfw'] || false
295
- if nsfw_channel_required && !is_channel_nsfw
296
- @logger.debug "Permission Check: Command requires NSFW channel, but current channel is NOT NSFW marked. Denying."
297
- return false
298
- elsif !nsfw_channel_required && is_channel_nsfw
299
- @logger.debug "Permission Check: Command does not require NSFW channel, but is in NSFW marked channel. Allowing."
300
- end
301
- end
302
-
303
- if !required_permissions.empty?
304
- @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)."
305
- return false
306
- end
307
- @logger.debug "Permission Check: All checks passed. Allowing command."
308
- true
309
- end
310
-
311
- def process_message(message)
312
- unless @ready_event_received
313
- @logger.debug "AN ERROR HAS OCCURED: Bot is not ready for commands"
314
- return
315
- end
316
-
317
- unless @selfbot
318
- return if message['author'] == @user_id
319
- end
320
-
321
- content = message['content']&.strip
322
- return if content.nil? || content.empty?
323
- @logger.debug "Full Message object received in process_message: #{message.inspect}"
324
-
325
- @commands.each do |cmd_name, cmd_data|
326
- command_full_string = "#{@prefix}#{cmd_name}"
327
- if content.downcase.start_with?(command_full_string.downcase)
328
- args_string = content[command_full_string.length..]&.strip
329
- args = args_string.to_s.split(/\s+/)
330
- args = [] if args == ['']
331
- # --- Permission Check (Pass both permissions and nsfw_channel_required to check_permissions) ---
332
- if check_permissions(message, cmd_data['permissions'], cmd_data['nsfw_cmd'])
333
- @logger.debug "Executing command: '#{cmd_name}' with args: #{args.inspect}"
334
- cmd_data['block'].call(message, args)
335
- else
336
- channel_id = message['channel']
337
- if cmd_data['nsfw_cmd'] && message['member']
338
- channel_details = get_channel_details(channel_id)
339
- if channel_details && !channel_details['nsfw']
340
- self.send_message(channel_id, text: "⛔ A NSFW marked channel is required to use the following command: `#{cmd_name}`")
341
- else
342
- self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
343
- end
344
- else
345
- self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
346
- end
347
- end
348
- return
349
- end
350
- end
351
-
352
- @message_handlers.each do |handler|
353
- @logger.debug "Calling general message handler for: '#{content}'"
354
- handler.call(message)
355
- end
356
- end
357
-
358
- def send_message(channel_id, text: nil, embeds: nil, masquerade_name: nil, masquerade_avatar_url: nil)
359
- if text.nil? && (embeds.nil? || embeds.empty?)
360
- @logger.debug "AN ERROR HAS OCCURED: Cannot send empty message or embeds."
361
- return
362
- end
363
-
364
- payload = {}
365
- payload[:content] = text if text
366
-
367
- if embeds && !embeds.empty?
368
- filtered_embeds = embeds.map do |embed|
369
- supported_keys = ['title', 'description', 'colour', 'url', 'icon_url', 'media']
370
- embed.select { |k, v| supported_keys.include?(k.to_s) }
371
- end
372
- payload[:embeds] = filtered_embeds
373
- end
374
- if masquerade_name || masquerade_avatar_url
375
- payload[:masquerade] = {}
376
- payload[:masquerade][:name] = masquerade_name if masquerade_name
377
- payload[:masquerade][:avatar] = masquerade_avatar_url if masquerade_avatar_url
378
- end
379
-
380
- @request_queue.enqueue do
381
- @logger.debug "Attempting to send message to channel '#{channel_id}' via queue..."
382
- uri = URI("#{@api_url}/channels/#{channel_id}/messages")
383
- req = Net::HTTP::Post.new(uri)
384
- _add_auth_header(req)
385
- req['Content-Type'] = 'application/json'
386
- req.body = payload.to_json
387
-
388
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
389
- http.request(req)
390
- end
391
-
392
- if res.is_a?(Net::HTTPSuccess)
393
- @logger.debug "Message sent successfully!"
394
- else
395
- @logger.debug "AN ERROR HAS OCCURED: Failed to send message: #{res.message} (Code: #{res.code})"
396
- @logger.debug "Response Body: #{res.body}"
397
- end
398
- end
399
- rescue => e
400
- @logger.debug "AN ERROR HAS OCCURED: Error enqueuing message: #{e.message}"
401
- @logger.debug e.backtrace.join("\n")
402
- end
403
-
404
- def find_unicode_emoji(shortcode)
405
- EMOJI_MAP[shortcode]
406
- end
407
-
408
- def add_reaction(channel_id, message_id, emoji_id)
409
- if emoji_id.start_with?(':') && emoji_id.end_with?(':')
410
- @logger.debug "AN ERROR HAS OCCURED: Cannot add reaction with shortcode '#{emoji_id}'. Please use the actual Unicode emoji or a custom emoji ID."
411
- return
412
- end
413
-
414
- @request_queue.enqueue do
415
- @logger.debug "Attempting to add reaction '#{emoji_id}' to message '#{message_id}' in channel '#{channel_id}' via queue."
416
- encoded_emoji_id = CGI.escape(emoji_id)
417
-
418
- uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}")
419
- req = Net::HTTP::Put.new(uri)
420
- _add_auth_header(req)
421
-
422
- res = nil
423
- begin
424
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10, open_timeout: 10) do |http|
425
- http.request(req)
426
- end
427
- rescue Net::ReadTimeout => e
428
- @logger.debug "AN ERROR HAS OCCURED: The network request timed out while waiting for a response after 10 seconds. Error: #{e.message}"
429
- return
430
- rescue Net::OpenTimeout => e
431
- @logger.debug "AN ERROR HAS OCCURED: The network request timed out while trying to open a connection after 10 seconds. Error: #{e.message}"
432
- return
433
- rescue => e
434
- @logger.debug "AN ERROR HAS OCCURED: An unexpected error occurred during the network request. Error: #{e.message}"
435
- return
436
- ensure
437
- @logger.debug "Network request to add reaction finished."
438
- end
439
-
440
- @logger.debug "Received response with code: #{res.code}"
441
- if res.is_a?(Net::HTTPSuccess) || res.code == '204'
442
- @logger.debug "Reaction added successfully!"
443
- else
444
- @logger.debug "AN ERROR HAS OCCURED: Failed to add reaction: #{res.message} (Code: #{res.code})"
445
- @logger.debug "Response Body: #{res.body}"
446
- if res.code == '403'
447
- @logger.debug "HINT: The bot may be missing the 'AddReactions' permission in this channel or server."
448
- end
449
- end
450
- end
451
- end
452
-
453
- def remove_reaction(channel_id, message_id, emoji_id, user_id: nil)
454
- target_user_id = user_id || @user_id
455
- @request_queue.enqueue do
456
- @logger.debug "Attempting to remove reaction '#{emoji_id}' from message '#{message_id}' by user '#{target_user_id}' in channel '#{channel_id}' via queue."
457
-
458
- encoded_emoji_id = CGI.escape(emoji_id)
459
-
460
- uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}?user_id=#{target_user_id}")
461
- req = Net::HTTP::Delete.new(uri)
462
- _add_auth_header(req)
463
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
464
- http.request(req)
465
- end
466
- if res.is_a?(Net::HTTPSuccess) || res.code == '204'
467
- @logger.debug "Reaction removed successfully!"
468
- else
469
- @logger.debug "AN ERROR HAS OCCURED: Failed to remove reaction: #{res.message} (Code: #{res.code})"
470
- @logger.debug "Response Body: #{res.body}"
471
- end
472
- end
473
- rescue => e
474
- @logger.debug "AN ERROR HAS OCCURED: Error enqueuing remove reaction: #{e.message}"
475
- @logger.debug e.backtrace.join("\n")
476
- end
477
-
478
- def get_channel_details(channel_id)
479
- @logger.debug "Fetching channel details for ID: #{channel_id} (direct API call for permission check)."
480
- uri = URI("#{@api_url}/channels/#{channel_id}")
481
- req = Net::HTTP::Get.new(uri)
482
- req['x-bot-token'] = @token
483
-
484
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
485
- http.request(req)
486
- end
487
-
488
- if res.is_a?(Net::HTTPSuccess)
489
- JSON.parse(res.body)
490
- else
491
- @logger.debug "AN ERROR HAS OCCURED: Failed to fetch channel details for #{channel_id}: #{res.message} (Code: #{res.code})"
492
- nil
493
- end
494
- rescue => e
495
- @logger.debug "AN ERROR HAS OCCURED: Error fetching channel details: #{e.message}"
496
- nil
497
- end
498
-
499
- def get_server_info(server_id)
500
- @logger.debug "get_server_info called with server_id: '#{server_id}' (Type: #{server_id.class}, Length: #{server_id.length})"
501
- @logger.debug "Available server IDs in cache (@servers.keys): #{@servers.keys.inspect}"
502
- found_server = @servers[server_id]
503
- @logger.debug "Result of @servers[server_id]: #{found_server.inspect}"
504
- found_server
505
- end
506
- def get_server_name(server_id)
507
- @servers[server_id]&.[]('name')
508
- end
509
-
510
- def stop
511
- @logger.debug "Stopping bot..."
512
- @running = false
513
- if @websocket_thread && @websocket_thread.alive?
514
- unless @websocket_thread.join(5)
515
- @logger.debug "WebSocket thread did not terminate gracefully, forcing kill."
516
- @websocket_thread.kill
517
- end
518
- @logger.debug "WebSocket thread terminated."
519
- end
520
- if @websocket && @websocket.open?
521
- @websocket.close
522
- @logger.debug "WebSocket closed."
523
- end
524
- @request_queue.stop_processing
525
- @logger.debug "Bot stopped."
526
- end
527
-
528
- private
529
-
530
- def _add_auth_header(request)
531
- if @selfbot
532
- request['x-session-token'] = @token
533
- else
534
- request['x-bot-token'] = @token
535
- end
536
- end
537
- end
1
+ # lib/revoltrb/bot.rb
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'websocket-client-simple' # This is required for WebSocket communication
5
+ require 'thread'
6
+ require 'time'
7
+ require_relative 'debuglogger'
8
+ require_relative 'request_queue'
9
+ require_relative 'webhooks'
10
+
11
+ module Revoltrb
12
+ class RevoltBot
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.revolt.chat', websocket_url: 'wss://app.revolt.chat/events', cdn_url: 'https://cdn.revoltusercontent.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 = Revoltrb::DebugLogger.new(debuglogs)
50
+ @request_queue = Revoltrb::RequestQueue.new(500)
51
+ @webhooks = Revoltrb::Webhooks.new(api_url, @logger, token, selfbot)
52
+
53
+ @prefix = "!"
54
+ @prefix = prefix if prefix
55
+ @selfbot = selfbot
56
+ @logger.debug "RevoltBot 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 Revolt.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 "Error: Essential properties (_id, username) missing or nil in API response from /users/@me."
101
+ @logger.debug "Please inspect the 'Response Body' above for unexpected format."
102
+ return false
103
+ end
104
+
105
+ @logger.debug "Successfully identified as #{@bot_name} (ID: #{@user_id}) via REST API."
106
+ @logger.debug "Bot owner ID after parsing: #{@bot_owner_id}"
107
+ @logger.debug "Owner ID: #{@bot_owner_id}, Discoverable: #{@bot_discoverable.inspect}, Created: #{@bot_creation_date}"
108
+ else
109
+ @logger.debug "Initial REST API call failed: #{res.message} (Code: #{res.code})"
110
+ @logger.debug "Response Body: #{res.body}"
111
+ @logger.debug "Please check your bot token. Cannot proceed with WebSocket connection."
112
+ return false
113
+ end
114
+ # Step 2: Connect to the WebSocket
115
+ @running = true
116
+ connect_websocket
117
+ @request_queue.start_processing
118
+ true
119
+ end
120
+
121
+ def connect_websocket
122
+ if @websocket && @websocket.open? && @running
123
+ @logger.debug "WebSocket already open and running."
124
+ return
125
+ end
126
+
127
+ @logger.debug "Connecting to WebSocket: #{@websocket_url}"
128
+ ws_url_with_token = "#{@websocket_url}?token=#{@token}"
129
+ bot_instance = self
130
+ thread_logger = @logger
131
+
132
+ @websocket_thread = Thread.new do
133
+ begin
134
+ @websocket = WebSocket::Client::Simple.connect ws_url_with_token
135
+ @websocket.on :open do
136
+ thread_logger.debug "WebSocket connection opened!"
137
+ end
138
+ @websocket.on :message do |msg|
139
+ bot_instance.handle_websocket_message(msg.data)
140
+ end
141
+ @websocket.on :close do |e|
142
+ close_code = e&.code || 'N/A'
143
+ close_reason = e&.reason || 'No reason provided'
144
+ thread_logger.debug "WebSocket closed: #{close_code} - #{close_reason}."
145
+ bot_instance.instance_variable_set(:@websocket, nil)
146
+ if bot_instance.instance_variable_get(:@running)
147
+ thread_logger.debug "Attempting to reconnect in 5 seconds..."
148
+ sleep 5
149
+ bot_instance.connect_websocket
150
+ else
151
+ thread_logger.debug "BOT: Bot has stopped and will not try to reconnect" # Use local thread_logger
152
+ end
153
+ end
154
+ @websocket.on :error do |e|
155
+ error_message = e&.message || 'Unknown error'
156
+ thread_logger.debug "WebSocket error: #{error_message}"
157
+ @websocket.close if @websocket&.open?
158
+ end
159
+
160
+ while bot_instance.instance_variable_get(:@running)
161
+ if Time.now.to_i - @last_heartbeat_sent > @heartbeat_interval
162
+ bot_instance.send_heartbeat
163
+ end
164
+ sleep 1
165
+ end
166
+ thread_logger.debug "WebSocket thread loop finished."
167
+ rescue => e
168
+ thread_logger.debug "WebSocket thread unhandled exception: #{e.message}"
169
+ thread_logger.debug e.backtrace.join("\n")
170
+ bot_instance.instance_variable_set(:@websocket, nil)
171
+ if bot_instance.instance_variable_get(:@running)
172
+ thread_logger.debug "Attempting to reconnect in 5 seconds due to unhandled error..."
173
+ sleep 5
174
+ bot_instance.connect_websocket
175
+ else
176
+ thread_logger.debug "Bot is stopped, not attempting to reconnect after unhandled error."
177
+ end
178
+ end
179
+ end
180
+ sleep 1
181
+ end
182
+
183
+ def send_heartbeat
184
+ if @websocket && @websocket.open?
185
+ payload = { type: 'Ping', data: Time.now.to_i }
186
+ @websocket.send(payload.to_json)
187
+ @last_heartbeat_sent = Time.now.to_i
188
+ end
189
+ rescue OpenSSL::SSL::SSLError => e
190
+ @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat (SSL): #{e.message}"
191
+ @websocket&.close
192
+ rescue => e
193
+ @logger.debug "AN ERROR HAS OCCURED: Error sending heartbeat: #{e.message}"
194
+ @websocket&.close
195
+ end
196
+
197
+ def handle_websocket_message(raw_data)
198
+ begin
199
+ event = JSON.parse(raw_data)
200
+ event_type = event['type']
201
+ case event_type
202
+ when 'Ready'
203
+ @logger.debug "Received 'Ready' event. Populating initial data..."
204
+ if event['servers']
205
+ event['servers'].each do |server_data|
206
+ @servers[server_data['_id']] = {
207
+ 'name' => server_data['name'],
208
+ 'id' => server_data['_id']
209
+ }
210
+ @logger.debug "Stored server ID from Ready event: #{server_data['_id'].inspect}" # New debug
211
+ end
212
+ @logger.debug "Loaded #{event['servers'].count} real servers from 'Ready' event."
213
+ else
214
+ @logger.debug "'Ready' event received but no 'servers' array found."
215
+ end
216
+ @ready_event_received = true
217
+ @logger.debug "@ready_event_received set to true."
218
+ when 'Message'
219
+ unless event['author'] == @user_id
220
+ process_message(event)
221
+ end
222
+ when 'Authenticated'
223
+ @logger.debug "Successfully authenticated with WebSocket."
224
+ when 'Pong'
225
+ # @logger.debug "Received Pong response."
226
+ when 'Error'
227
+ @logger.debug "AN ERROR HAS OCCURED: Revolt API Error received via WebSocket: #{event['error']}"
228
+ else
229
+ # @logger.debug "AN ERROR HAS OCCURED: Unhandled WebSocket event type: #{event_type}"
230
+ end
231
+ rescue JSON::ParserError => e
232
+ @logger.debug "Failed to parse WebSocket message as JSON: #{e.message}"
233
+ @logger.debug "Raw message: #{raw_data}"
234
+ rescue => e
235
+ @logger.debug "Error processing WebSocket message: #{e.message}"
236
+ @logger.debug e.backtrace.join("\n")
237
+ end
238
+ end
239
+
240
+ def on_message(&block)
241
+ @message_handlers << block
242
+ end
243
+ def command(command_name, required_permissions: [], nsfw_channel_required: false, &block)
244
+ cmd_key = command_name.to_s.downcase
245
+ @commands[cmd_key] = {
246
+ 'block' => block,
247
+ 'permissions' => required_permissions,
248
+ 'nsfw_cmd' => nsfw_channel_required
249
+ }
250
+ @logger.debug "Command '#{command_name}' registered with permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
251
+ end
252
+
253
+ def check_permissions(message, required_permissions, nsfw_channel_required)
254
+ @logger.debug "check_permissions called with required_permissions: #{required_permissions.inspect}, NSFW required: #{nsfw_channel_required}."
255
+ @logger.debug "message['channel'] value: #{message['channel'].inspect}"
256
+
257
+ server_id_from_message = message['member']&.[]('_id')&.[]('server')
258
+ @logger.debug "message['member']['_id']['server'] value: #{server_id_from_message.inspect}"
259
+
260
+ if required_permissions.empty? && !nsfw_channel_required
261
+ @logger.debug "Permission Check: No specific permissions or NSFW requirement. Allowing command."
262
+ return true
263
+ end
264
+ user_id = message['author']
265
+
266
+ @logger.debug "\n--- PERMISSION DEBUG START ---"
267
+ @logger.debug "User ID from message (user_id): '#{user_id}' | Bot Owner ID stored (@bot_owner_id): '#{@bot_owner_id}'"
268
+ @logger.debug "Are they equal? (user_id == @bot_owner_id): #{user_id == @bot_owner_id}"
269
+ # @logger.debug "User ID char codes: #{user_id.each_char.map(&:ord).join(', ')}"
270
+ # @logger.debug "Bot Owner ID char codes: #{@bot_owner_id.each_char.map(&:ord).join(', ')}"
271
+ @logger.debug "--- PERMISSION DEBUG END ---\n"
272
+ # --- BotOwner Check ---
273
+ if required_permissions.include?('BotOwner')
274
+ if user_id == @bot_owner_id
275
+ @logger.debug "Permission Check: User is the bot owner. Allowing command."
276
+ return true
277
+ else
278
+ @logger.debug "Permission Check: User is NOT the bot owner. Denying command."
279
+ return false
280
+ end
281
+ end
282
+ # --- NSFW Channel Check ---
283
+ channel_id = message['channel']
284
+ if server_id_from_message.nil?
285
+ if nsfw_channel_required
286
+ @logger.debug "Permission Check: Command requires NSFW channel, but message is in DM. Denying command."
287
+ return false
288
+ end
289
+ else
290
+ channel_details = get_channel_details(channel_id)
291
+ if channel_details.nil?
292
+ @logger.debug "Permission Check: Command requires NSFW channel but could not retrieve channel details. Denying command to be safe."
293
+ return false
294
+ end
295
+ is_channel_nsfw = channel_details['nsfw'] || false
296
+ if nsfw_channel_required && !is_channel_nsfw
297
+ @logger.debug "Permission Check: Command requires NSFW channel, but current channel is NOT NSFW marked. Denying."
298
+ return false
299
+ elsif !nsfw_channel_required && is_channel_nsfw
300
+ @logger.debug "Permission Check: Command does not require NSFW channel, but is in NSFW marked channel. Allowing."
301
+ end
302
+ end
303
+
304
+ if !required_permissions.empty?
305
+ @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)."
306
+ return false
307
+ end
308
+ @logger.debug "Permission Check: All checks passed. Allowing command."
309
+ true
310
+ end
311
+
312
+ def process_message(message)
313
+ unless @ready_event_received
314
+ @logger.debug "AN ERROR HAS OCCURED: Bot is not ready for commands"
315
+ return
316
+ end
317
+
318
+ unless @selfbot
319
+ return if message['author'] == @user_id
320
+ end
321
+
322
+ content = message['content']&.strip
323
+ return if content.nil? || content.empty?
324
+ @logger.debug "Full Message object received in process_message: #{message.inspect}"
325
+
326
+ @commands.each do |cmd_name, cmd_data|
327
+ command_full_string = "#{@prefix}#{cmd_name}"
328
+ if content.downcase.start_with?(command_full_string.downcase)
329
+ args_string = content[command_full_string.length..]&.strip
330
+ args = args_string.to_s.split(/\s+/)
331
+ args = [] if args == ['']
332
+ # --- Permission Check (Pass both permissions and nsfw_channel_required to check_permissions) ---
333
+ if check_permissions(message, cmd_data['permissions'], cmd_data['nsfw_cmd'])
334
+ @logger.debug "Executing command: '#{cmd_name}' with args: #{args.inspect}"
335
+ cmd_data['block'].call(message, args)
336
+ else
337
+ channel_id = message['channel']
338
+ if cmd_data['nsfw_cmd'] && message['member']
339
+ channel_details = get_channel_details(channel_id)
340
+ if channel_details && !channel_details['nsfw']
341
+ self.send_message(channel_id, text: "⛔ A NSFW marked channel is required to use the following command: `#{cmd_name}`")
342
+ else
343
+ self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
344
+ end
345
+ else
346
+ self.send_message(channel_id, text: "⛔ You don't have permission to use the `#{cmd_name}` command.")
347
+ end
348
+ end
349
+ return
350
+ end
351
+ end
352
+
353
+ @message_handlers.each do |handler|
354
+ @logger.debug "Calling general message handler for: '#{content}'"
355
+ handler.call(message)
356
+ end
357
+ end
358
+
359
+ def send_message(channel_id, text: nil, embeds: nil, masquerade_name: nil, masquerade_avatar_url: nil)
360
+ if text.nil? && (embeds.nil? || embeds.empty?)
361
+ @logger.debug "AN ERROR HAS OCCURED: Cannot send empty message or embeds."
362
+ return
363
+ end
364
+
365
+ payload = {}
366
+ payload[:content] = text if text
367
+
368
+ if embeds && !embeds.empty?
369
+ filtered_embeds = embeds.map do |embed|
370
+ supported_keys = ['title', 'description', 'colour', 'url', 'icon_url', 'media']
371
+ embed.select { |k, v| supported_keys.include?(k.to_s) }
372
+ end
373
+ payload[:embeds] = filtered_embeds
374
+ end
375
+ if masquerade_name || masquerade_avatar_url
376
+ payload[:masquerade] = {}
377
+ payload[:masquerade][:name] = masquerade_name if masquerade_name
378
+ payload[:masquerade][:avatar] = masquerade_avatar_url if masquerade_avatar_url
379
+ end
380
+
381
+ @request_queue.enqueue do
382
+ @logger.debug "Attempting to send message to channel '#{channel_id}' via queue..."
383
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages")
384
+ req = Net::HTTP::Post.new(uri)
385
+ _add_auth_header(req)
386
+ req['Content-Type'] = 'application/json'
387
+ req.body = payload.to_json
388
+
389
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
390
+ http.request(req)
391
+ end
392
+
393
+ if res.is_a?(Net::HTTPSuccess)
394
+ @logger.debug "Message sent successfully!"
395
+ else
396
+ @logger.debug "AN ERROR HAS OCCURED: Failed to send message: #{res.message} (Code: #{res.code})"
397
+ @logger.debug "Response Body: #{res.body}"
398
+ end
399
+ end
400
+ rescue => e
401
+ @logger.debug "AN ERROR HAS OCCURED: Error enqueuing message: #{e.message}"
402
+ @logger.debug e.backtrace.join("\n")
403
+ end
404
+
405
+ def find_unicode_emoji(shortcode)
406
+ EMOJI_MAP[shortcode]
407
+ end
408
+
409
+ def add_reaction(channel_id, message_id, emoji_id)
410
+ if emoji_id.start_with?(':') && emoji_id.end_with?(':')
411
+ @logger.debug "AN ERROR HAS OCCURED: Cannot add reaction with shortcode '#{emoji_id}'. Please use the actual Unicode emoji or a custom emoji ID."
412
+ return
413
+ end
414
+
415
+ @request_queue.enqueue do
416
+ @logger.debug "Attempting to add reaction '#{emoji_id}' to message '#{message_id}' in channel '#{channel_id}' via queue."
417
+ encoded_emoji_id = CGI.escape(emoji_id)
418
+
419
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}")
420
+ req = Net::HTTP::Put.new(uri)
421
+ _add_auth_header(req)
422
+
423
+ res = nil
424
+ begin
425
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10, open_timeout: 10) do |http|
426
+ http.request(req)
427
+ end
428
+ rescue Net::ReadTimeout => e
429
+ @logger.debug "AN ERROR HAS OCCURED: The network request timed out while waiting for a response after 10 seconds. Error: #{e.message}"
430
+ return
431
+ rescue Net::OpenTimeout => e
432
+ @logger.debug "AN ERROR HAS OCCURED: The network request timed out while trying to open a connection after 10 seconds. Error: #{e.message}"
433
+ return
434
+ rescue => e
435
+ @logger.debug "AN ERROR HAS OCCURED: An unexpected error occurred during the network request. Error: #{e.message}"
436
+ return
437
+ ensure
438
+ @logger.debug "Network request to add reaction finished."
439
+ end
440
+
441
+ @logger.debug "Received response with code: #{res.code}"
442
+ if res.is_a?(Net::HTTPSuccess) || res.code == '204'
443
+ @logger.debug "Reaction added successfully!"
444
+ else
445
+ @logger.debug "AN ERROR HAS OCCURED: Failed to add reaction: #{res.message} (Code: #{res.code})"
446
+ @logger.debug "Response Body: #{res.body}"
447
+ if res.code == '403'
448
+ @logger.debug "HINT: The bot may be missing the 'AddReactions' permission in this channel or server."
449
+ end
450
+ end
451
+ end
452
+ end
453
+
454
+ def remove_reaction(channel_id, message_id, emoji_id, user_id: nil)
455
+ target_user_id = user_id || @user_id
456
+ @request_queue.enqueue do
457
+ @logger.debug "Attempting to remove reaction '#{emoji_id}' from message '#{message_id}' by user '#{target_user_id}' in channel '#{channel_id}' via queue."
458
+
459
+ encoded_emoji_id = CGI.escape(emoji_id)
460
+
461
+ uri = URI("#{@api_url}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded_emoji_id}?user_id=#{target_user_id}")
462
+ req = Net::HTTP::Delete.new(uri)
463
+ _add_auth_header(req)
464
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
465
+ http.request(req)
466
+ end
467
+ if res.is_a?(Net::HTTPSuccess) || res.code == '204'
468
+ @logger.debug "Reaction removed successfully!"
469
+ else
470
+ @logger.debug "AN ERROR HAS OCCURED: Failed to remove reaction: #{res.message} (Code: #{res.code})"
471
+ @logger.debug "Response Body: #{res.body}"
472
+ end
473
+ end
474
+ rescue => e
475
+ @logger.debug "AN ERROR HAS OCCURED: Error enqueuing remove reaction: #{e.message}"
476
+ @logger.debug e.backtrace.join("\n")
477
+ end
478
+
479
+ def get_channel_details(channel_id)
480
+ @logger.debug "Fetching channel details for ID: #{channel_id} (direct API call for permission check)."
481
+ uri = URI("#{@api_url}/channels/#{channel_id}")
482
+ req = Net::HTTP::Get.new(uri)
483
+ req['x-bot-token'] = @token
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
+ JSON.parse(res.body)
491
+ else
492
+ @logger.debug "AN ERROR HAS OCCURED: Failed to fetch channel details for #{channel_id}: #{res.message} (Code: #{res.code})"
493
+ nil
494
+ end
495
+ rescue => e
496
+ @logger.debug "AN ERROR HAS OCCURED: Error fetching channel details: #{e.message}"
497
+ nil
498
+ end
499
+
500
+ def get_server_info(server_id)
501
+ @logger.debug "get_server_info called with server_id: '#{server_id}' (Type: #{server_id.class}, Length: #{server_id.length})"
502
+ @logger.debug "Available server IDs in cache (@servers.keys): #{@servers.keys.inspect}"
503
+ found_server = @servers[server_id]
504
+ @logger.debug "Result of @servers[server_id]: #{found_server.inspect}"
505
+ found_server
506
+ end
507
+ def get_server_name(server_id)
508
+ @servers[server_id]&.[]('name')
509
+ end
510
+
511
+ def stop
512
+ @logger.debug "Stopping bot..."
513
+ @running = false
514
+ if @websocket_thread && @websocket_thread.alive?
515
+ unless @websocket_thread.join(5)
516
+ @logger.debug "WebSocket thread did not terminate gracefully, forcing kill."
517
+ @websocket_thread.kill
518
+ end
519
+ @logger.debug "WebSocket thread terminated."
520
+ end
521
+ if @websocket && @websocket.open?
522
+ @websocket.close
523
+ @logger.debug "WebSocket closed."
524
+ end
525
+ @request_queue.stop_processing
526
+ @logger.debug "Bot stopped."
527
+ end
528
+
529
+ private
530
+
531
+ def _add_auth_header(request)
532
+ if @selfbot
533
+ request['x-session-token'] = @token
534
+ else
535
+ request['x-bot-token'] = @token
536
+ end
537
+ end
538
+ end
538
539
  end