discord_rda 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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +398 -0
  4. data/lib/discord_rda/bot.rb +842 -0
  5. data/lib/discord_rda/cache/configurable_cache.rb +283 -0
  6. data/lib/discord_rda/cache/entity_cache.rb +184 -0
  7. data/lib/discord_rda/cache/memory_store.rb +143 -0
  8. data/lib/discord_rda/cache/redis_store.rb +136 -0
  9. data/lib/discord_rda/cache/store.rb +56 -0
  10. data/lib/discord_rda/connection/gateway_client.rb +383 -0
  11. data/lib/discord_rda/connection/invalid_bucket.rb +205 -0
  12. data/lib/discord_rda/connection/rate_limiter.rb +280 -0
  13. data/lib/discord_rda/connection/request_queue.rb +340 -0
  14. data/lib/discord_rda/connection/reshard_manager.rb +328 -0
  15. data/lib/discord_rda/connection/rest_client.rb +316 -0
  16. data/lib/discord_rda/connection/rest_proxy.rb +165 -0
  17. data/lib/discord_rda/connection/scalable_rest_client.rb +526 -0
  18. data/lib/discord_rda/connection/shard_manager.rb +223 -0
  19. data/lib/discord_rda/core/async_runtime.rb +108 -0
  20. data/lib/discord_rda/core/configuration.rb +194 -0
  21. data/lib/discord_rda/core/logger.rb +188 -0
  22. data/lib/discord_rda/core/snowflake.rb +121 -0
  23. data/lib/discord_rda/entity/attachment.rb +88 -0
  24. data/lib/discord_rda/entity/base.rb +103 -0
  25. data/lib/discord_rda/entity/channel.rb +446 -0
  26. data/lib/discord_rda/entity/channel_builder.rb +280 -0
  27. data/lib/discord_rda/entity/color.rb +253 -0
  28. data/lib/discord_rda/entity/embed.rb +221 -0
  29. data/lib/discord_rda/entity/emoji.rb +89 -0
  30. data/lib/discord_rda/entity/factory.rb +99 -0
  31. data/lib/discord_rda/entity/guild.rb +619 -0
  32. data/lib/discord_rda/entity/member.rb +263 -0
  33. data/lib/discord_rda/entity/message.rb +405 -0
  34. data/lib/discord_rda/entity/message_builder.rb +369 -0
  35. data/lib/discord_rda/entity/role.rb +157 -0
  36. data/lib/discord_rda/entity/support.rb +294 -0
  37. data/lib/discord_rda/entity/user.rb +231 -0
  38. data/lib/discord_rda/entity/value_objects.rb +263 -0
  39. data/lib/discord_rda/event/auto_moderation.rb +294 -0
  40. data/lib/discord_rda/event/base.rb +986 -0
  41. data/lib/discord_rda/event/bus.rb +225 -0
  42. data/lib/discord_rda/event/scheduled_event.rb +257 -0
  43. data/lib/discord_rda/hot_reload_manager.rb +303 -0
  44. data/lib/discord_rda/interactions/application_command.rb +436 -0
  45. data/lib/discord_rda/interactions/command_system.rb +484 -0
  46. data/lib/discord_rda/interactions/components.rb +464 -0
  47. data/lib/discord_rda/interactions/interaction.rb +553 -0
  48. data/lib/discord_rda/plugin/analytics_plugin.rb +528 -0
  49. data/lib/discord_rda/plugin/base.rb +190 -0
  50. data/lib/discord_rda/plugin/registry.rb +126 -0
  51. data/lib/discord_rda/version.rb +5 -0
  52. data/lib/discord_rda.rb +70 -0
  53. metadata +302 -0
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ # Production-ready Invalid Request Bucket - Prevents 1-hour Discord bans.
5
+ # Implements global request pausing when approaching invalid request limits.
6
+ #
7
+ class InvalidRequestBucket
8
+ # Default values per Discord's documentation
9
+ DEFAULT_LIMIT = 10_000
10
+ DEFAULT_INTERVAL = 10 * 60 * 1000 # 10 minutes in milliseconds
11
+ WARNING_THRESHOLD = 100 # Warn when remaining drops below this
12
+ PAUSE_THRESHOLD = 50 # Pause all requests when remaining drops below this
13
+
14
+ # @return [Integer] Maximum invalid requests allowed
15
+ attr_reader :limit
16
+
17
+ # @return [Integer] Time window in milliseconds
18
+ attr_reader :interval
19
+
20
+ # @return [Integer] Current remaining requests
21
+ attr_reader :remaining
22
+
23
+ # @return [Logger] Logger instance
24
+ attr_reader :logger
25
+
26
+ # @return [Boolean] Whether globally paused due to approaching limit
27
+ attr_reader :globally_paused
28
+
29
+ # Initialize invalid request bucket
30
+ # @param limit [Integer] Maximum invalid requests (default: 10000)
31
+ # @param interval [Integer] Time window in milliseconds (default: 600000)
32
+ # @param logger [Logger] Logger instance
33
+ def initialize(limit: DEFAULT_LIMIT, interval: DEFAULT_INTERVAL, logger: nil)
34
+ @limit = limit
35
+ @interval = interval
36
+ @remaining = limit
37
+ @logger = logger
38
+ @mutex = Mutex.new
39
+ @frozen_at = nil
40
+ @reset_timer = nil
41
+ @globally_paused = false
42
+ @pause_condition = Async::Condition.new
43
+ end
44
+
45
+ # Wait until a request is allowed (not rate limited by invalid requests)
46
+ # Blocks if we've hit the invalid request limit or if globally paused
47
+ # @return [void]
48
+ def wait_until_request_available
49
+ @mutex.synchronize do
50
+ # Wait if globally paused
51
+ while @globally_paused
52
+ @logger&.warn('Waiting: Globally paused due to invalid request limit')
53
+ @mutex.unlock
54
+ @pause_condition.wait
55
+ @mutex.lock
56
+ end
57
+
58
+ if @remaining <= PAUSE_THRESHOLD && !@globally_paused
59
+ @globally_paused = true
60
+ @logger&.error('GLOBAL PAUSE ACTIVATED: Approaching invalid request limit!', remaining: @remaining)
61
+ end
62
+
63
+ if @remaining <= 0 && @frozen_at
64
+ now = Time.now.to_f * 1000
65
+ future = @frozen_at + @interval
66
+ wait_time = [(future - now) / 1000.0, 0].max
67
+
68
+ if wait_time > 0
69
+ @logger&.error('Invalid request bucket exhausted! Waiting to prevent 1-hour ban.', wait_seconds: wait_time.round(2))
70
+ @mutex.unlock
71
+ sleep(wait_time)
72
+ @mutex.lock
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ # Check if a request is allowed
79
+ # @return [Boolean] True if request can be made
80
+ def request_allowed?
81
+ @mutex.synchronize do
82
+ return false if @globally_paused
83
+ return true if @remaining > 0
84
+ return true unless @frozen_at
85
+
86
+ now = Time.now.to_f * 1000
87
+ now >= (@frozen_at + @interval)
88
+ end
89
+ end
90
+
91
+ # Handle a completed request response
92
+ # @param status [Integer] HTTP status code
93
+ # @return [void]
94
+ def handle_request(status)
95
+ # Only count 401, 403, 429, and 502 as invalid requests
96
+ return unless invalid_status?(status)
97
+
98
+ @mutex.synchronize do
99
+ @frozen_at ||= Time.now.to_f * 1000
100
+ @remaining -= 1
101
+
102
+ @logger&.debug('Invalid request counted', status: status, remaining: @remaining, limit: @limit)
103
+
104
+ # Schedule automatic reset
105
+ schedule_reset unless @reset_timer
106
+
107
+ # Check thresholds
108
+ if @remaining == WARNING_THRESHOLD
109
+ @logger&.warn('Approaching invalid request limit!', remaining: @remaining)
110
+ elsif @remaining == PAUSE_THRESHOLD
111
+ @globally_paused = true
112
+ @logger&.error('CRITICAL: Pausing all requests to prevent 1-hour Discord ban!', remaining: @remaining)
113
+ elsif @remaining <= 0
114
+ @logger&.error('INVALID REQUEST LIMIT REACHED! All requests blocked for 10 minutes.')
115
+ end
116
+ end
117
+ end
118
+
119
+ # Release global pause (call after interval or manual intervention)
120
+ # @return [void]
121
+ def release_pause
122
+ @mutex.synchronize do
123
+ was_paused = @globally_paused
124
+ @globally_paused = false
125
+ @logger&.info('Global pause released. Resuming normal request processing.') if was_paused
126
+ @pause_condition.signal
127
+ end
128
+ end
129
+
130
+ # Reset the bucket (after interval has passed)
131
+ # @return [void]
132
+ def reset
133
+ @mutex.synchronize do
134
+ old_remaining = @remaining
135
+ @remaining = @limit
136
+ @frozen_at = nil
137
+ @reset_timer = nil
138
+ was_paused = @globally_paused
139
+ @globally_paused = false
140
+
141
+ if old_remaining < WARNING_THRESHOLD
142
+ @logger&.info('Invalid request bucket reset', previous_remaining: old_remaining)
143
+ end
144
+
145
+ @pause_condition.signal if was_paused
146
+ end
147
+ end
148
+
149
+ # Get current status with detailed information
150
+ # @return [Hash] Bucket status
151
+ def status
152
+ @mutex.synchronize do
153
+ now = Time.now.to_f * 1000
154
+ reset_in = if @frozen_at && @remaining <= 0
155
+ [(@frozen_at + @interval - now) / 1000.0, 0].max
156
+ else
157
+ nil
158
+ end
159
+
160
+ {
161
+ limit: @limit,
162
+ remaining: @remaining,
163
+ used: @limit - @remaining,
164
+ interval: @interval,
165
+ interval_minutes: @interval / 60000.0,
166
+ frozen_at: @frozen_at ? Time.at(@frozen_at / 1000.0) : nil,
167
+ reset_in_seconds: reset_in,
168
+ globally_paused: @globally_paused,
169
+ request_allowed: request_allowed?,
170
+ warning_threshold: WARNING_THRESHOLD,
171
+ pause_threshold: PAUSE_THRESHOLD,
172
+ healthy: @remaining > WARNING_THRESHOLD
173
+ }
174
+ end
175
+ end
176
+
177
+ # Check if bucket is healthy
178
+ # @return [Boolean] True if well above warning threshold
179
+ def healthy?
180
+ @mutex.synchronize { @remaining > WARNING_THRESHOLD }
181
+ end
182
+
183
+ # Get percentage of remaining requests
184
+ # @return [Float] Percentage (0-100)
185
+ def health_percentage
186
+ @mutex.synchronize { (@remaining.to_f / @limit) * 100 }
187
+ end
188
+
189
+ private
190
+
191
+ def invalid_status?(status)
192
+ # Discord counts these as invalid requests
193
+ [401, 403, 429, 502].include?(status)
194
+ end
195
+
196
+ def schedule_reset
197
+ return if @reset_timer
198
+
199
+ @reset_timer = Async do |task|
200
+ task.sleep(@interval / 1000.0)
201
+ reset
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async/semaphore'
4
+ require 'async/condition'
5
+
6
+ module DiscordRDA
7
+ # Production-ready Rate Limiter for Discord REST API.
8
+ # Implements precise token bucket algorithm with async timer-based resets.
9
+ #
10
+ class RateLimiter
11
+ # Rate limit info structure
12
+ RateLimitInfo = Struct.new(:limit, :remaining, :reset, :reset_after, :bucket, :last_updated, keyword_init: true)
13
+
14
+ # @return [Hash<String, RateLimitInfo>] Rate limit info per route
15
+ attr_reader :limits
16
+
17
+ # @return [Hash<String, Array<Async::Condition>]>] Waiters per route
18
+ attr_reader :waiters
19
+
20
+ # @return [Logger] Logger instance
21
+ attr_reader :logger
22
+
23
+ # @return [Float] Global rate limit reset timestamp
24
+ attr_reader :global_reset_at
25
+
26
+ # Initialize rate limiter
27
+ # @param logger [Logger] Logger instance
28
+ def initialize(logger: nil)
29
+ @logger = logger
30
+ @limits = {}
31
+ @waiters = Hash.new { |h, k| h[k] = [] }
32
+ @mutex = Mutex.new
33
+ @global_reset_at = nil
34
+ @timers = {}
35
+ @semaphore = Async::Semaphore.new(1)
36
+ end
37
+
38
+ # Acquire permission to make a request with precise timing
39
+ # @param route [String] Route identifier
40
+ # @return [void]
41
+ def acquire(route)
42
+ # Check global rate limit first
43
+ wait_for_global if @global_reset_at
44
+
45
+ # Check route-specific limit with precise timing
46
+ info = @mutex.synchronize { @limits[route] }
47
+ return unless info
48
+
49
+ if info.remaining <= 0 && info.reset_after > 0
50
+ now = Time.now.to_f
51
+ wait_time = [info.reset_after - (now - info.last_updated.to_f), 0].max
52
+
53
+ if wait_time > 0
54
+ @logger&.info('Rate limited, waiting', route: route, seconds: wait_time.round(3), bucket: info.bucket)
55
+ precise_sleep(wait_time)
56
+ end
57
+ end
58
+ end
59
+
60
+ # Update rate limit info from response headers with precise timing
61
+ # @param route [String] Route identifier
62
+ # @param response [Protocol::HTTP::Response] HTTP response
63
+ # @return [void]
64
+ def update(route, response)
65
+ headers = response.headers
66
+
67
+ # Check for global rate limit
68
+ global = headers['x-ratelimit-global']
69
+ if global == 'true'
70
+ retry_after = headers['retry-after']&.to_f || 1.0
71
+ @global_reset_at = Time.now.to_f + retry_after
72
+ @logger&.error('Global rate limit hit', reset_in: retry_after)
73
+ schedule_global_reset(retry_after)
74
+ notify_waiters(route)
75
+ return
76
+ end
77
+
78
+ # Parse rate limit headers
79
+ limit = headers['x-ratelimit-limit']&.to_i
80
+ remaining = headers['x-ratelimit-remaining']&.to_i
81
+ reset = headers['x-ratelimit-reset']&.to_f
82
+ reset_after = headers['x-ratelimit-reset-after']&.to_f
83
+ bucket = headers['x-ratelimit-bucket']
84
+
85
+ return unless limit
86
+
87
+ now = Time.now
88
+ info = RateLimitInfo.new(
89
+ limit: limit,
90
+ remaining: remaining || 0,
91
+ reset: reset ? Time.at(reset) : nil,
92
+ reset_after: reset_after || 0,
93
+ bucket: bucket,
94
+ last_updated: now
95
+ )
96
+
97
+ @mutex.synchronize { @limits[route] = info }
98
+
99
+ # Schedule precise reset timer if depleted
100
+ if remaining && remaining <= 0 && reset_after && reset_after > 0
101
+ schedule_route_reset(route, reset_after)
102
+ end
103
+
104
+ @logger&.debug('Rate limit updated', route: route, bucket: bucket, remaining: remaining, reset_after: reset_after&.round(3))
105
+ end
106
+
107
+ # Get rate limit info for a route
108
+ # @param route [String] Route identifier
109
+ # @return [RateLimitInfo, nil] Rate limit info
110
+ def info(route)
111
+ @mutex.synchronize { @limits[route] }
112
+ end
113
+
114
+ # Check if route is rate limited
115
+ # @param route [String] Route identifier
116
+ # @return [Boolean] True if limited
117
+ def limited?(route)
118
+ info = @mutex.synchronize { @limits[route] }
119
+ return false unless info
120
+
121
+ if info.remaining > 0
122
+ false
123
+ elsif info.reset_after <= 0
124
+ false
125
+ else
126
+ now = Time.now.to_f
127
+ elapsed = now - info.last_updated.to_f
128
+ elapsed < info.reset_after
129
+ end
130
+ end
131
+
132
+ # Get time until reset for a route
133
+ # @param route [String] Route identifier
134
+ # @return [Float, nil] Seconds until reset, or nil if not limited
135
+ def time_until_reset(route)
136
+ info = @mutex.synchronize { @limits[route] }
137
+ return nil unless info
138
+ return nil if info.remaining > 0
139
+
140
+ elapsed = Time.now.to_f - info.last_updated.to_f
141
+ [info.reset_after - elapsed, 0].max
142
+ end
143
+
144
+ # Get reset time for a route
145
+ # @param route [String] Route identifier
146
+ # @return [Time, nil] Reset time
147
+ def reset_time(route)
148
+ @mutex.synchronize { @limits[route]&.reset }
149
+ end
150
+
151
+ # Get bucket ID for a route
152
+ # @param route [String] Route identifier
153
+ # @return [String, nil] Bucket ID
154
+ def bucket_id(route)
155
+ @mutex.synchronize { @limits[route]&.bucket }
156
+ end
157
+
158
+ # Wait for a route to be available (async-friendly)
159
+ # @param route [String] Route identifier
160
+ # @return [void]
161
+ def wait_for_route(route)
162
+ return unless limited?(route)
163
+
164
+ wait_time = time_until_reset(route)
165
+ return unless wait_time && wait_time > 0
166
+
167
+ condition = Async::Condition.new
168
+ @mutex.synchronize { @waiters[route] << condition }
169
+
170
+ @logger&.debug('Waiting for route', route: route, seconds: wait_time.round(3))
171
+
172
+ Async do |task|
173
+ task.sleep(wait_time)
174
+ condition.signal
175
+ end
176
+
177
+ condition.wait
178
+ end
179
+
180
+ # Clear all rate limits
181
+ # @return [void]
182
+ def clear
183
+ @mutex.synchronize do
184
+ @limits.clear
185
+ @timers.each_value(&:stop) if @timers
186
+ @timers.clear
187
+ @global_reset_at = nil
188
+ end
189
+ end
190
+
191
+ # Get comprehensive status
192
+ # @return [Hash] Rate limiter status
193
+ def status
194
+ @mutex.synchronize do
195
+ {
196
+ global_limited: @global_reset_at && Time.now.to_f < @global_reset_at,
197
+ global_reset_in: @global_reset_at ? [@global_reset_at - Time.now.to_f, 0].max : nil,
198
+ routes_tracked: @limits.size,
199
+ routes: @limits.transform_values do |info|
200
+ {
201
+ limit: info.limit,
202
+ remaining: info.remaining,
203
+ reset_after: info.reset_after,
204
+ bucket: info.bucket,
205
+ limited: limited?(@limits.key(info))
206
+ }
207
+ end
208
+ }
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ def wait_for_global
215
+ return unless @global_reset_at
216
+
217
+ now = Time.now.to_f
218
+ if now < @global_reset_at
219
+ wait_time = @global_reset_at - now
220
+ @logger&.warn('Waiting for global rate limit', seconds: wait_time.round(3))
221
+ precise_sleep(wait_time)
222
+ end
223
+ @global_reset_at = nil
224
+ end
225
+
226
+ def precise_sleep(seconds)
227
+ return if seconds <= 0
228
+
229
+ # Use Async sleep for async context, regular sleep otherwise
230
+ if defined?(Async::Task) && Async::Task.current?
231
+ Async::Task.current.sleep(seconds)
232
+ else
233
+ sleep(seconds)
234
+ end
235
+ end
236
+
237
+ def schedule_global_reset(seconds)
238
+ return if seconds <= 0
239
+
240
+ Async do |task|
241
+ task.sleep(seconds)
242
+ @global_reset_at = nil
243
+ notify_all_waiters
244
+ end
245
+ end
246
+
247
+ def schedule_route_reset(route, seconds)
248
+ return if seconds <= 0
249
+
250
+ # Cancel existing timer
251
+ @timers[route]&.stop
252
+
253
+ @timers[route] = Async do |task|
254
+ task.sleep(seconds)
255
+ @mutex.synchronize do
256
+ if @limits[route]
257
+ @limits[route] = @limits[route].dup
258
+ @limits[route].remaining = @limits[route].limit
259
+ @limits[route].last_updated = Time.now
260
+ end
261
+ end
262
+ notify_waiters(route)
263
+ end
264
+ end
265
+
266
+ def notify_waiters(route)
267
+ waiters = @mutex.synchronize { @waiters.delete(route) || [] }
268
+ waiters.each(&:signal)
269
+ end
270
+
271
+ def notify_all_waiters
272
+ waiters = @mutex.synchronize do
273
+ all = @waiters.values.flatten
274
+ @waiters.clear
275
+ all
276
+ end
277
+ waiters.each(&:signal)
278
+ end
279
+ end
280
+ end