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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +39 -0
- data/lib/rubord/components/actionRow.rb +93 -0
- data/lib/rubord/components/button.rb +125 -0
- data/lib/rubord/components/componentsV2.rb +4 -0
- data/lib/rubord/components/containers/base.rb +15 -0
- data/lib/rubord/components/containers/container.rb +39 -0
- data/lib/rubord/components/containers/section.rb +26 -0
- data/lib/rubord/components/containers/separator.rb +51 -0
- data/lib/rubord/components/containers/text.rb +23 -0
- data/lib/rubord/components/modal.rb +134 -0
- data/lib/rubord/components/select_menu.rb +147 -0
- data/lib/rubord/models/channel.rb +50 -0
- data/lib/rubord/models/collection.rb +70 -0
- data/lib/rubord/models/commands/base.rb +111 -0
- data/lib/rubord/models/commands/command.rb +3 -0
- data/lib/rubord/models/commands/loader.rb +36 -0
- data/lib/rubord/models/commands/registry.rb +26 -0
- data/lib/rubord/models/components.rb +5 -0
- data/lib/rubord/models/embed.rb +87 -0
- data/lib/rubord/models/flags.rb +249 -0
- data/lib/rubord/models/guild.rb +78 -0
- data/lib/rubord/models/interaction.rb +136 -0
- data/lib/rubord/models/member.rb +63 -0
- data/lib/rubord/models/mention.rb +47 -0
- data/lib/rubord/models/message.rb +88 -0
- data/lib/rubord/models/role.rb +15 -0
- data/lib/rubord/models/user.rb +21 -0
- data/lib/rubord/structs/client.rb +364 -0
- data/lib/rubord/structs/gateway.rb +363 -0
- data/lib/rubord/structs/logger.rb +19 -0
- data/lib/rubord/structs/models.rb +19 -0
- data/lib/rubord/structs/parser.rb +68 -0
- data/lib/rubord/structs/rate_limiter.rb +163 -0
- data/lib/rubord/structs/rest.rb +353 -0
- data/lib/rubord.rb +8 -0
- 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
|