rubord 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +39 -0
  4. data/lib/rubord/components/actionRow.rb +93 -0
  5. data/lib/rubord/components/button.rb +125 -0
  6. data/lib/rubord/components/componentsV2.rb +4 -0
  7. data/lib/rubord/components/containers/base.rb +15 -0
  8. data/lib/rubord/components/containers/container.rb +39 -0
  9. data/lib/rubord/components/containers/section.rb +26 -0
  10. data/lib/rubord/components/containers/separator.rb +51 -0
  11. data/lib/rubord/components/containers/text.rb +23 -0
  12. data/lib/rubord/components/modal.rb +134 -0
  13. data/lib/rubord/components/select_menu.rb +147 -0
  14. data/lib/rubord/models/channel.rb +50 -0
  15. data/lib/rubord/models/collection.rb +70 -0
  16. data/lib/rubord/models/commands/base.rb +111 -0
  17. data/lib/rubord/models/commands/command.rb +3 -0
  18. data/lib/rubord/models/commands/loader.rb +36 -0
  19. data/lib/rubord/models/commands/registry.rb +26 -0
  20. data/lib/rubord/models/components.rb +5 -0
  21. data/lib/rubord/models/embed.rb +87 -0
  22. data/lib/rubord/models/flags.rb +249 -0
  23. data/lib/rubord/models/guild.rb +78 -0
  24. data/lib/rubord/models/interaction.rb +136 -0
  25. data/lib/rubord/models/member.rb +63 -0
  26. data/lib/rubord/models/mention.rb +47 -0
  27. data/lib/rubord/models/message.rb +88 -0
  28. data/lib/rubord/models/role.rb +15 -0
  29. data/lib/rubord/models/user.rb +21 -0
  30. data/lib/rubord/structs/client.rb +364 -0
  31. data/lib/rubord/structs/gateway.rb +363 -0
  32. data/lib/rubord/structs/logger.rb +19 -0
  33. data/lib/rubord/structs/models.rb +19 -0
  34. data/lib/rubord/structs/parser.rb +68 -0
  35. data/lib/rubord/structs/rate_limiter.rb +163 -0
  36. data/lib/rubord/structs/rest.rb +353 -0
  37. data/lib/rubord.rb +8 -0
  38. metadata +105 -0
@@ -0,0 +1,19 @@
1
+ module Rubord
2
+ module Logger
3
+ def self.success(message)
4
+ puts "\e[32m✔ #{message}\e[0m"
5
+ end
6
+
7
+ def self.info(message)
8
+ puts "\e[36mℹ #{message}\e[0m"
9
+ end
10
+
11
+ def self.warn(message)
12
+ puts "\e[33m⚠ #{message}\e[0m"
13
+ end
14
+
15
+ def self.error(message)
16
+ puts "\e[31m✖ #{message}\e[0m"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "../models/components"
2
+ require_relative "../models/channel"
3
+ require_relative "../models/embed"
4
+ require_relative "../models/guild"
5
+ require_relative "../models/member"
6
+ require_relative "../models/mention"
7
+ require_relative "../models/message"
8
+ require_relative "../models/role"
9
+ require_relative "../models/user"
10
+ require_relative "../models/collection"
11
+ require_relative "../models/flags"
12
+ require_relative "../models/interaction"
13
+ require_relative "../models/commands/command.rb"
14
+ require_relative "logger.rb"
15
+
16
+ module Rubord
17
+ class GatewayError < StandardError; end
18
+ class InvalidTokenError < GatewayError; end
19
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ COLORS = {
4
+ red: "#ED4245",
5
+ orange: "#FEE75C",
6
+ yellow: "#F1C40F",
7
+ green: "#57F287",
8
+ teal: "#1ABC9C",
9
+ blue: "#3498DB",
10
+ blurple: "#5865F2",
11
+ purple: "#9B59B6",
12
+ pink: "#EB459E",
13
+ white: "#FFFFFF",
14
+ gray: "#95A5A6",
15
+ dark_gray: "#2C2F33",
16
+ black: "#000000"
17
+ }.freeze
18
+
19
+ module Rubord
20
+ class Parser
21
+ CHANNEL_MENTION_REGEX = /<#(\d{17,22})>/.freeze
22
+
23
+ def timestamp(value)
24
+ return value if value.is_a?(Time)
25
+ return nil unless value
26
+
27
+ Time.parse(value.to_s)
28
+ rescue ArgumentError
29
+ nil
30
+ end
31
+
32
+ def color(value)
33
+ return nil if value.nil?
34
+
35
+ case value
36
+ when Symbol
37
+ hex_to_int(COLORS[value])
38
+ when String
39
+ if COLORS.key?(value.to_sym)
40
+ hex_to_int(COLORS[value.to_sym])
41
+ else
42
+ hex_to_int(value)
43
+ end
44
+ else
45
+ value.to_i
46
+ end
47
+ end
48
+
49
+ def channel_mentions(text)
50
+ return [] unless text
51
+
52
+ text.scan(CHANNEL_MENTION_REGEX).flatten.map(&:to_i)
53
+ end
54
+
55
+ private
56
+
57
+ def hex_to_int(hex)
58
+ return nil unless hex
59
+
60
+ hex = hex.delete_prefix("#")
61
+ hex.to_i(16)
62
+ end
63
+ end
64
+
65
+ def self.Parser
66
+ @parser ||= Parser.new
67
+ end
68
+ end
@@ -0,0 +1,163 @@
1
+ module Rubord
2
+ class RateLimiter
3
+ class Bucket
4
+ attr_reader :id, :limit, :remaining, :reset_at, :queue, :last_request_at
5
+
6
+ def initialize(id)
7
+ @id = id
8
+ @limit = 1
9
+ @remaining = 1
10
+ @reset_at = Time.now
11
+ @queue = []
12
+ @last_request_at = nil
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def available?
17
+ @mutex.synchronize do
18
+ now = Time.now
19
+
20
+ if now >= @reset_at
21
+ @remaining = @limit
22
+ @reset_at = now + 1
23
+ end
24
+
25
+ @remaining > 0
26
+ end
27
+ end
28
+
29
+ def wait
30
+ @mutex.synchronize do
31
+ now = Time.now
32
+
33
+ if now < @reset_at && @remaining <= 0
34
+ sleep_time = @reset_at - now
35
+ sleep(sleep_time) if sleep_time > 0
36
+ @remaining = @limit
37
+ end
38
+ end
39
+ end
40
+
41
+ def update_from_headers(headers)
42
+ @mutex.synchronize do
43
+ limit_header = headers["x-ratelimit-limit"]
44
+ remaining_header = headers["x-ratelimit-remaining"]
45
+ reset_after_header = headers["x-ratelimit-reset-after"]
46
+ reset_header = headers["x-ratelimit-reset"]
47
+
48
+ limit_value = Array(limit_header).first
49
+ remaining_value = Array(remaining_header).first
50
+ reset_after_value = Array(reset_after_header).first
51
+ reset_value = Array(reset_header).first
52
+
53
+ @limit = limit_value&.to_i || @limit
54
+ @remaining = remaining_value&.to_i || @remaining
55
+
56
+ if reset_after_value
57
+ @reset_at = Time.now + reset_after_value.to_f
58
+ elsif reset_value
59
+ @reset_at = Time.at(reset_value.to_i)
60
+ end
61
+
62
+ @last_request_at = Time.now
63
+ end
64
+ end
65
+
66
+ def reset_in
67
+ [0, @reset_at - Time.now].max
68
+ end
69
+ end
70
+
71
+ class GlobalLimiter
72
+ def initialize
73
+ @reset_at = Time.now
74
+ @mutex = Mutex.new
75
+ end
76
+
77
+ def blocked?
78
+ @mutex.synchronize { Time.now < @reset_at }
79
+ end
80
+
81
+ def block_for(seconds)
82
+ @mutex.synchronize do
83
+ @reset_at = Time.now + seconds
84
+ end
85
+ end
86
+
87
+ def wait
88
+ @mutex.synchronize do
89
+ if Time.now < @reset_at
90
+ sleep_time = @reset_at - Time.now
91
+ sleep(sleep_time) if sleep_time > 0
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def initialize
98
+ @buckets = {}
99
+ @route_buckets = {}
100
+ @global_limiter = GlobalLimiter.new
101
+ @mutex = Mutex.new
102
+ @default_bucket = Bucket.new("default")
103
+ end
104
+
105
+ def bucket_for(route)
106
+ @mutex.synchronize do
107
+ bucket_id = @route_buckets[route]
108
+
109
+ if bucket_id
110
+ @buckets[bucket_id] ||= Bucket.new(bucket_id)
111
+ else
112
+ @default_bucket
113
+ end
114
+ end
115
+ end
116
+
117
+ def update_mapping(route, bucket_id)
118
+ @mutex.synchronize do
119
+ @route_buckets[route] = bucket_id
120
+ @buckets[bucket_id] ||= Bucket.new(bucket_id)
121
+ end
122
+ end
123
+
124
+ def wait_global
125
+ @global_limiter.wait
126
+ end
127
+
128
+ def wait_for(route)
129
+ bucket = bucket_for(route)
130
+ bucket.wait
131
+ end
132
+
133
+ def handle_rate_limit(headers, body)
134
+ if body["global"] == true
135
+ @global_limiter.block_for(body["retry_after"])
136
+ return :global
137
+ else
138
+ bucket_header = headers["x-ratelimit-bucket"]
139
+ bucket_id = Array(bucket_header).first if bucket_header
140
+
141
+ if bucket_id
142
+ @mutex.synchronize do
143
+ @buckets[bucket_id] ||= Bucket.new(bucket_id)
144
+ @buckets[bucket_id].reset_at = Time.now + body["retry_after"]
145
+ @buckets[bucket_id].remaining = 0
146
+ end
147
+ end
148
+ return :route
149
+ end
150
+ end
151
+
152
+ def update_bucket(route, headers)
153
+ bucket_header = headers["x-ratelimit-bucket"]
154
+ bucket_id = Array(bucket_header).first if bucket_header
155
+ return unless bucket_id
156
+
157
+ update_mapping(route, bucket_id)
158
+
159
+ bucket = @buckets[bucket_id]
160
+ bucket.update_from_headers(headers)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,353 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "openssl"
4
+ require "json"
5
+ require_relative "rate_limiter"
6
+
7
+ if ENV["TERMUX_VERSION"]
8
+ OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:verify_mode] =
9
+ OpenSSL::SSL::VERIFY_PEER
10
+
11
+ OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:verify_callback] =
12
+ proc do |preverify_ok, ssl_ctx|
13
+ return true if preverify_ok
14
+
15
+ ssl_ctx.error == OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL
16
+ end
17
+ end
18
+
19
+ module Rubord
20
+ class REST
21
+ BASE_URL = "https://discord.com/api/v10"
22
+
23
+ # Default retry configuration
24
+ MAX_RETRIES = 3
25
+ INITIAL_RETRY_DELAY = 0.5
26
+
27
+ def initialize(token)
28
+ @token = token
29
+ @rate_limiter = RateLimiter.new
30
+ @http_pool = {} # Simple HTTP connection pool
31
+ end
32
+
33
+ # ========== MÉTODOS PÚBLICOS DA API ==========
34
+
35
+ def send_message(channel_id, **opts)
36
+ body = build_message_body(**opts)
37
+ request(:post, "/channels/#{channel_id}/messages", body: body)
38
+ end
39
+
40
+ def reply_message(channel_id, message_id, **opts)
41
+ body = build_message_body(**opts)
42
+ body[:message_reference] = {
43
+ message_id: message_id,
44
+ channel_id: channel_id
45
+ }
46
+ request(:post, "/channels/#{channel_id}/messages", body: body)
47
+ end
48
+
49
+ def edit_message(channel_id, message_id, **opts)
50
+ body = build_message_body(**opts)
51
+ request(:patch, "/channels/#{channel_id}/messages/#{message_id}", body: body)
52
+ end
53
+
54
+ def delete_message(channel_id, message_id)
55
+ request(:delete, "/channels/#{channel_id}/messages/#{message_id}")
56
+ end
57
+
58
+ def get_channel(channel_id)
59
+ request(:get, "/channels/#{channel_id}")
60
+ end
61
+
62
+ def get_message(channel_id, message_id)
63
+ request(:get, "/channels/#{channel_id}/messages/#{message_id}")
64
+ end
65
+
66
+ def get_user(user_id)
67
+ request(:get, "/users/#{user_id}")
68
+ end
69
+
70
+ def get_guild(guild_id)
71
+ request(:get, "/guilds/#{guild_id}")
72
+ end
73
+
74
+ def get_guild_member(guild_id, user_id)
75
+ request(:get, "/guilds/#{guild_id}/members/#{user_id}")
76
+ end
77
+
78
+ def kick_guild_member(guild_id, user_id, reason: nil)
79
+ path = "/guilds/#{guild_id}/members/#{user_id}"
80
+ path += "?reason=#{URI.encode(reason)}" if reason
81
+ request(:delete, path)
82
+ end
83
+
84
+ def ban_guild_member(guild_id, user_id, delete_message_days: 0, reason: nil)
85
+ path = "/guilds/#{guild_id}/bans/#{user_id}?delete_message_days=#{delete_message_days}"
86
+ path += "&reason=#{URI.encode(reason)}" if reason
87
+ request(:put, path)
88
+ end
89
+
90
+ def unban_guild_member(guild_id, user_id, reason: nil)
91
+ path = "/guilds/#{guild_id}/bans/#{user_id}"
92
+ path += "?reason=#{URI.encode(reason)}" if reason
93
+ request(:delete, path)
94
+ end
95
+
96
+ def get_application
97
+ request(:get, "/oauth2/applications/@me")
98
+ end
99
+
100
+ def interactions_response(interaction_id, interaction_token, type:, data: nil)
101
+ body = { type: type }
102
+ body[:data] = data if data
103
+ request(:post, "/interactions/#{interaction_id}/#{interaction_token}/callback", body: body)
104
+ end
105
+
106
+ def interaction_edit(application_id, token, **opts)
107
+ body = build_message_body(**opts)
108
+ request(:patch, "/webhooks/#{application_id}/#{token}/messages/@original", body: body)
109
+ end
110
+
111
+ def interaction_followup(application_id, token, **opts)
112
+ body = build_message_body(**opts)
113
+ request(:post, "/webhooks/#{application_id}/#{token}", body: body)
114
+ end
115
+
116
+ def build_message_body(content: nil, embeds: nil, components: nil, flags: nil)
117
+ body = {}
118
+
119
+ resolved_flags =
120
+ case flags
121
+ when Array
122
+ Rubord::MessageFlags.combine(*flags)
123
+ when Symbol
124
+ Rubord::MessageFlags.combine(flags)
125
+ when Integer
126
+ flags
127
+ else
128
+ nil
129
+ end
130
+
131
+ is_components_v2 =
132
+ resolved_flags &&
133
+ (resolved_flags & Rubord::MessageFlags::COMPONENTS_V2 != 0)
134
+
135
+ if is_components_v2
136
+ if content || embeds
137
+ raise ArgumentError, "content/embeds cannot be used with Components V2. Use Rubord::Text or other components."
138
+ end
139
+ else
140
+ body[:content] = content if content
141
+ body[:embeds] = Array(embeds).map(&:to_h) if embeds
142
+ end
143
+
144
+ body[:components] = Array(components).map(&:to_h) if components
145
+ body[:flags] = resolved_flags if resolved_flags
146
+
147
+ body
148
+ end
149
+
150
+ def handle_response(res)
151
+ parse_response(res)
152
+ end
153
+
154
+ # ========== MÉTODO REQUEST PÚBLICO ==========
155
+
156
+ def request(method, path, body: nil, retries: MAX_RETRIES)
157
+ route = extract_route(method, path)
158
+ uri = URI("#{BASE_URL}#{path}")
159
+
160
+ # Wait for rate limits
161
+ @rate_limiter.wait_global
162
+ @rate_limiter.wait_for(route)
163
+
164
+ req = build_request(method, uri)
165
+ req.body = JSON.generate(body) if body
166
+
167
+ begin
168
+ res = send_http_request(uri, req)
169
+
170
+ # Handle rate limit responses
171
+ if res.code.to_i == 429
172
+ return handle_rate_limit_response(res, route, method, path, body, retries)
173
+ end
174
+
175
+ # Handle other errors
176
+ unless res.is_a?(Net::HTTPSuccess)
177
+ raise_api_error(res)
178
+ end
179
+
180
+ # Update rate limit info
181
+ update_rate_limit_info(route, res)
182
+
183
+ # Parse and return response
184
+ return parse_response(res)
185
+
186
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
187
+ return handle_timeout(e, method, path, body, retries)
188
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
189
+ return handle_connection_error(e, method, path, body, retries)
190
+ rescue => e
191
+ # Handle any other unexpected errors
192
+ puts "[REST Error] Unexpected error: #{e.class}: #{e.message}"
193
+ raise e
194
+ end
195
+ end
196
+
197
+ # ========== MÉTODOS PRIVADOS ==========
198
+ private
199
+
200
+ # Extract route identifier for rate limiting
201
+ def extract_route(method, path)
202
+ # Remove IDs from path for better bucket matching
203
+ clean_path = path.gsub(/\d+/, ":id")
204
+ "#{method.upcase}:#{clean_path}"
205
+ end
206
+
207
+ # Send HTTP request with connection pooling
208
+ def send_http_request(uri, req)
209
+ http = @http_pool[uri.host] ||= Net::HTTP.new(uri.hostname, uri.port)
210
+ http.use_ssl = uri.scheme == "https"
211
+ http.read_timeout = 30
212
+ http.open_timeout = 10
213
+
214
+ http.start unless http.started?
215
+ http.request(req)
216
+ end
217
+
218
+ # Handle rate limit response (429)
219
+ def handle_rate_limit_response(res, route, method, path, body, retries)
220
+ retry_after = extract_retry_after(res)
221
+ is_global = parse_rate_limit_body(res).fetch("global", false)
222
+
223
+ puts "[Rate Limit] #{is_global ? 'Global' : 'Route'} rate limit hit on #{route}. Waiting #{retry_after}s"
224
+
225
+ if is_global
226
+ @rate_limiter.handle_rate_limit(res.to_hash, parse_rate_limit_body(res))
227
+ end
228
+
229
+ sleep(retry_after)
230
+
231
+ # Retry the request
232
+ if retries > 0
233
+ return request(method, path, body: body, retries: retries - 1)
234
+ else
235
+ raise "Rate limit retries exhausted for #{route}"
236
+ end
237
+ end
238
+
239
+ # Update rate limit information from successful response
240
+ def update_rate_limit_info(route, res)
241
+ headers = res.to_hash.transform_keys(&:downcase)
242
+ @rate_limiter.update_bucket(route, headers)
243
+ end
244
+
245
+ # Parse response body
246
+ def parse_response(res)
247
+ return nil if res.body.nil? || res.body.empty?
248
+
249
+ JSON.parse(res.body)
250
+ rescue JSON::ParserError
251
+ res.body
252
+ end
253
+
254
+ # Extract retry-after time from response
255
+ def extract_retry_after(res)
256
+ body = parse_rate_limit_body(res)
257
+
258
+ # Try from body first, then header
259
+ retry_after = body["retry_after"] if body.is_a?(Hash)
260
+ retry_after ||= res["retry-after"] || res["Retry-After"]
261
+ retry_after ||= 1.0
262
+
263
+ retry_after.to_f
264
+ end
265
+
266
+ # Parse rate limit response body
267
+ def parse_rate_limit_body(res)
268
+ return {} if res.body.nil? || res.body.empty?
269
+
270
+ begin
271
+ JSON.parse(res.body)
272
+ rescue JSON::ParserError
273
+ {}
274
+ end
275
+ end
276
+
277
+ # Handle timeout errors with retry
278
+ def handle_timeout(error, method, path, body, retries)
279
+ puts "[Timeout] #{error.message} for #{method} #{path}"
280
+
281
+ if retries > 0
282
+ sleep(INITIAL_RETRY_DELAY * (MAX_RETRIES - retries + 1))
283
+ return request(method, path, body: body, retries: retries - 1)
284
+ else
285
+ raise "Request timeout after #{MAX_RETRIES} retries"
286
+ end
287
+ end
288
+
289
+ def handle_connection_error(error, method, path, body, retries)
290
+ puts "[Connection Error] #{error.class}: #{error.message}"
291
+
292
+ @http_pool.clear
293
+
294
+ if retries > 0
295
+ sleep(1 * (MAX_RETRIES - retries + 1))
296
+ return request(method, path, body: body, retries: retries - 1)
297
+ else
298
+ raise "Connection failed after #{MAX_RETRIES} retries"
299
+ end
300
+ end
301
+
302
+ def raise_api_error(res)
303
+ body = parse_response(res) || {}
304
+ message = body["message"] || res.message || "HTTP #{res.code}"
305
+
306
+ error_class = case res.code.to_i
307
+ when 400 then BadRequestError
308
+ when 401 then UnauthorizedError
309
+ when 403 then ForbiddenError
310
+ when 404 then NotFoundError
311
+ when 429 then RateLimitError
312
+ when 500..599 then ServerError
313
+ else APIError
314
+ end
315
+
316
+ raise error_class.new(message, res.code.to_i, body)
317
+ end
318
+
319
+ # Build HTTP request
320
+ def build_request(method, uri)
321
+ klass = {
322
+ get: Net::HTTP::Get,
323
+ post: Net::HTTP::Post,
324
+ put: Net::HTTP::Put,
325
+ patch: Net::HTTP::Patch,
326
+ delete: Net::HTTP::Delete
327
+ }.fetch(method)
328
+
329
+ req = klass.new(uri)
330
+ req["Authorization"] = "Bot #{@token}"
331
+ req["Content-Type"] = "application/json"
332
+ req["User-Agent"] = "DiscordBot (https://github.com/kauzxx00/rubord, 1.0.0)"
333
+ req
334
+ end
335
+ end
336
+
337
+ class APIError < StandardError
338
+ attr_reader :status, :response_body
339
+
340
+ def initialize(message, status = nil, response_body = nil)
341
+ super(message)
342
+ @status = status
343
+ @response_body = response_body
344
+ end
345
+ end
346
+
347
+ class BadRequestError < APIError; end
348
+ class UnauthorizedError < APIError; end
349
+ class ForbiddenError < APIError; end
350
+ class NotFoundError < APIError; end
351
+ class RateLimitError < APIError; end
352
+ class ServerError < APIError; end
353
+ end
data/lib/rubord.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "json"
2
+ require_relative "rubord/structs/client"
3
+ require_relative "rubord/structs/models"
4
+ require_relative "rubord/structs/parser"
5
+
6
+ module Rubord
7
+ VERSION = "0.1.3"
8
+ end