stoatrb 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d34d4d67e82ebfa41c47ace5c40ed7e07cef241bc66c86da2eae9267840946c3
4
+ data.tar.gz: 6c7fad3f522ca70369b4d8f292caa6535de5613a509abd6d08986d04315ac23a
5
+ SHA512:
6
+ metadata.gz: 7a11ed7db6ed626fe6f587ce08d29ad1f10ca0c69524d4ba335c54f273a89c4eea112bb84d3be81d69a282df9f3c716a7420c18242a0d0ac1933a6c9743cfc94
7
+ data.tar.gz: 1bd193a7980d36125fc1029fc3617cce06fb1ecd20ae303c9071fbbc39ece814dc183fdb2e86387825acdb84bc2d0fe359fe9a13fb0cbb43a6df3a8814da9023
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Roxanne Wolf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # stoatrb
2
+
3
+ Stoatrb is a Ruby package (a.k.a. Gem) that allows you to make stoat.chat bots and use stoat.chat webhooks using the Ruby programming language. This package (a.k.a. Gem) is not officially endorsed by stoat.chat amd this is not an official stoat.chat product.
4
+
5
+ You need Ruby 3.0 or newer in order to use this package (Ruby 3.2 or newer is recommended)
6
+
7
+ > [!NOTE]
8
+ > This package (a.k.a. Gem) is in an alpha state so expect things to be buggy and/or broken.
9
+
10
+ ## ToDo
11
+
12
+ This list contains a list of things that I know is broken and gotta fix. Contributing will be super helpful.
13
+
14
+ - Fix reactions support
15
+
16
+ ## Setup
17
+
18
+ You can install Revoltrb through the following methods:
19
+
20
+ #### Method 1: Install from Gemfile
21
+
22
+ Add the following to your Gemfile file and run the "[bundle install](https://rubygems.org/gems/stoatrb)" command:
23
+
24
+ ```ruby
25
+ gem 'revoltrb'
26
+ ```
27
+
28
+ or add the following to your Gemfile file.
29
+
30
+ ```ruby
31
+ gem 'revoltrb', git: 'https://gitlab.com/roxannewolf/stoatrb'
32
+ ```
33
+
34
+ #### Troubleshooting
35
+
36
+ If you encounter the "Exited with code: 16 output:Ignoring debug-1.7.1 because its extensions are not built." error, most likely, there is something wrong with the parser package. This can be fixed by installing parser manually (gem install parser)
37
+
38
+ ## Usage
39
+
40
+ You can make a simple bot like this:
41
+
42
+ ```ruby
43
+ require 'stoatrb'
44
+
45
+ bot = Stoatrb::StoatBot.new('REVOLT_BOT_TOKEN_HERE')
46
+
47
+ bot.on_message do |message|
48
+ next if message['author'] == bot.user_id
49
+ content = message['content']&.downcase
50
+ channel_id = message['channel']
51
+
52
+ if content.include?("ping")
53
+ bot.send_message(channel_id, text: "Pong!")
54
+ end
55
+ end
56
+
57
+ bot.run
58
+ ```
59
+
60
+ or you can make a bot with full prefix commands like this:
61
+
62
+ ```ruby
63
+ require 'stoatrb'
64
+
65
+ bot = Stoatrb::StoatBot.new('REVOLT_BOT_TOKEN_HERE', prefix: '!')
66
+
67
+ bot.command(:ping) do |message, args|
68
+ channel_id = message['channel']
69
+ bot.send_message(channel_id, text: "Pong! You sent: #{args.join(' ')}")
70
+ end
71
+
72
+ bot.run
73
+ ```
74
+
75
+ Webhook example:
76
+
77
+ ```ruby
78
+ require 'stoatrb'
79
+ # Webhook url should look like this: https://stoat.chat/api/webhooks/<WEBHOOK_ID>/<WEBHOOK_TOKEN>
80
+ WEBHOOK_ID = ''
81
+ WEBHOOK_TOKEN = ''
82
+ webhook = StoatWebhooks::Webhook.new(WEBHOOK_ID, WEBHOOK_TOKEN)
83
+
84
+ webhook.send_message(
85
+ content: "Stoatrb webhook message content",
86
+ )
87
+ ```
88
+
89
+ ## Support and Help
90
+
91
+ If you need help with this ruby package (a.k.a. Gem), feel free to join the [Roxanne Studios Stoat.chat Server](http://stt.gg/r4Ee2R1Z) and use the STOATRB category to talk about this package.
92
+
93
+ ## Contributing
94
+
95
+ We are working to support more of Stoat.chat's API. Remember, the creator of this package is only a Ruby beginner so contributing to this project will mean a lot and can help with more coverage with the Stoat.chat API. Opening issues and Pull requests are welcome at our [Gitlab repo](https://gitlab.com/roxannewolf/stoatrb) and [Codeberg repo](https://codeberg.org/roxannewolf/stoatrb)
@@ -0,0 +1,589 @@
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
589
+ end
@@ -0,0 +1,13 @@
1
+ # lib/stoatrb/debuglogger.rb
2
+
3
+ module Stoatrb
4
+ class DebugLogger
5
+ def initialize(enabled = false)
6
+ @enabled = enabled
7
+ end
8
+
9
+ def debug(message)
10
+ puts message if @enabled
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,59 @@
1
+ # lib/stoatrb/request_queue.rb
2
+
3
+ require 'thread'
4
+
5
+ module Stoatrb
6
+ class RequestQueue
7
+ def initialize(delay_between_requests_ms = 100)
8
+ @queue = []
9
+ @mutex = Mutex.new
10
+ @condition = ConditionVariable.new
11
+ @processing_thread = nil
12
+ @running = false
13
+ @delay_between_requests = delay_between_requests_ms / 1000.0
14
+ @logger = Stoatrb::DebugLogger.new
15
+ end
16
+
17
+ def enqueue(&block)
18
+ @mutex.synchronize do
19
+ @queue << block
20
+ @condition.signal
21
+ end
22
+ end
23
+
24
+ def start_processing
25
+ return if @running
26
+
27
+ @running = true
28
+ @processing_thread = Thread.new do
29
+ @logger.debug "RequestQueue processing thread started."
30
+ while @running
31
+ request = nil
32
+ @mutex.synchronize do
33
+ @condition.wait(@mutex) if @queue.empty?
34
+ request = @queue.shift unless @queue.empty?
35
+ end
36
+
37
+ if request
38
+ begin
39
+ request.call
40
+ sleep @delay_between_requests
41
+ rescue => e
42
+ @logger.debug "AN ERROR HAS OCCURED: Error processing queued request: #{e.message}"
43
+ @logger.debug e.backtrace.join("\n")
44
+ end
45
+ end
46
+ end
47
+ @logger.debug "RequestQueue processing thread stopped."
48
+ end
49
+ end
50
+
51
+ def stop_processing
52
+ @running = false
53
+ @mutex.synchronize do
54
+ @condition.signal
55
+ end
56
+ @processing_thread.join if @processing_thread&.alive?
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,6 @@
1
+ # lib/stoatrb/version.rb
2
+
3
+ module Stoatrb
4
+ # The current version of stoatrb
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,104 @@
1
+ # lib/stoatrb/webhooks.rb
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Stoatrb
7
+ class Webhooks
8
+ attr_reader :api_url, :logger, :token, :selfbot
9
+ def initialize(api_url, logger, token, selfbot)
10
+ @api_url = api_url
11
+ @logger = logger
12
+ @token = token
13
+ @selfbot = selfbot
14
+ end
15
+
16
+ def create_webhook(channel_id, name, avatar_url: nil)
17
+ @logger.debug "Attempting to create a webhook for channel '#{channel_id}' with name '#{name}'."
18
+
19
+ uri = URI("#{@api_url}/channels/#{channel_id}/webhooks")
20
+ req = Net::HTTP::Post.new(uri)
21
+ if @selfbot
22
+ req['Authorization'] = @token
23
+ else
24
+ req['x-bot-token'] = @token
25
+ end
26
+ req['Content-Type'] = 'application/json'
27
+
28
+ payload = {
29
+ name: name
30
+ }
31
+ payload[:avatar] = avatar_url unless avatar_url.nil?
32
+ req.body = payload.to_json
33
+
34
+ res = nil
35
+ begin
36
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 10, open_timeout: 10) do |http|
37
+ http.request(req)
38
+ end
39
+ rescue Net::ReadTimeout => e
40
+ @logger.debug "AN ERROR HAS OCCURED: Network request timed out while reading: #{e.message}"
41
+ return nil
42
+ rescue Net::OpenTimeout => e
43
+ @logger.debug "AN ERROR HAS OCCURED: Network request timed out while connecting: #{e.message}"
44
+ return nil
45
+ rescue => e
46
+ @logger.debug "AN ERROR HAS OCCURED: An unexpected error occurred while creating the webhook: #{e.message}"
47
+ return nil
48
+ end
49
+
50
+ if res.is_a?(Net::HTTPSuccess)
51
+ webhook_data = JSON.parse(res.body)
52
+ @logger.debug "Webhook created successfully! Details: #{res.body}"
53
+ @logger.debug "Webhook Name: #{webhook_data['name']} | Webhook ID: #{webhook_data['id']}"
54
+ @logger.debug "Webhook Channel ID: #{webhook_data['channel_id']}"
55
+ return webhook_data
56
+ else
57
+ @logger.debug "AN ERROR HAS OCCURED: Failed to create webhook. (Code: #{res.code}) Response: #{res.message}"
58
+ @logger.debug "Response Body: #{res.body}"
59
+ return nil
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ module StoatWebhooks
66
+ class Webhook
67
+ DEFAULT_API_URL = 'https://stoat.chat/api'.freeze
68
+ def initialize(webhook_id, webhook_token, api_url: DEFAULT_API_URL)
69
+ @webhook_id = webhook_id
70
+ @webhook_token = webhook_token
71
+ @api_url = api_url
72
+ @uri = URI("#{@api_url}/webhooks/#{@webhook_id}/#{@webhook_token}")
73
+ end
74
+
75
+ def send_message(content:, embeds: nil)
76
+ payload = {
77
+ content: content
78
+ }
79
+
80
+ if embeds
81
+ payload[:embeds] = Array(embeds)
82
+ end
83
+
84
+ http = Net::HTTP.new(@uri.host, @uri.port)
85
+ http.use_ssl = true
86
+ request = Net::HTTP::Post.new(@uri.path, 'Content-Type' => 'application/json')
87
+ request.body = payload.to_json
88
+
89
+ begin
90
+ response = http.request(request)
91
+ if response.code.to_i == 204 || response.code.to_i == 200
92
+ return true
93
+ else
94
+ puts "[Stoatrb Webhooks] AN ERROR HAS OCCURED: Status code: #{response.code}"
95
+ puts "[Stoatrb Webhooks] Response body: #{response.body}"
96
+ return false
97
+ end
98
+ rescue StandardError => e
99
+ puts "[Stoatrb Webhooks] AN ERROR HAS OCCURED: #{e.message}"
100
+ return false
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/stoatrb.rb ADDED
@@ -0,0 +1,14 @@
1
+ # lib/stoatrb.rb
2
+ require_relative 'stoatrb/bot'
3
+ require_relative 'stoatrb/version'
4
+ require_relative 'stoatrb/debuglogger'
5
+ require_relative 'stoatrb/request_queue'
6
+ require_relative 'stoatrb/webhooks'
7
+
8
+ module Stoatrb
9
+ # This module can serve as a namespace for the gem's classes. For now, it's a direct require.
10
+ end
11
+
12
+ module StoatWebhooks
13
+ # This module can serve as a namespace for the gem's classes. For now, it's a direct require.
14
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stoatrb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Roxanne Studios
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.13.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.13.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: net-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.6.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.6.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: websocket-client-simple
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.9.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: thread
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: parser
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 3.3.9.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 3.3.9.0
82
+ description: The first Ruby package (a.k.a. gem) to exist for making Stoat.chat bots
83
+ and using Stoat.chat webhooks. This project is not officially endorsed by stoat.chat
84
+ email:
85
+ - ''
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE
91
+ - README.md
92
+ - lib/stoatrb.rb
93
+ - lib/stoatrb/bot.rb
94
+ - lib/stoatrb/debuglogger.rb
95
+ - lib/stoatrb/request_queue.rb
96
+ - lib/stoatrb/version.rb
97
+ - lib/stoatrb/webhooks.rb
98
+ homepage: https://gitlab.com/roxannewolf/stoatrb
99
+ licenses:
100
+ - MIT
101
+ metadata: {}
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.0'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.6.9
117
+ specification_version: 4
118
+ summary: Ruby gem for making stoat.chat bots
119
+ test_files: []