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,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ # Enhanced scalable REST client inspired by Discordeno.
5
+ # Features: request queues, invalid request bucket, URL simplification, proxy support.
6
+ #
7
+ class ScalableRestClient
8
+ # Discord API base URL
9
+ API_BASE = 'https://discord.com/api/v10'
10
+
11
+ # Rate limit headers
12
+ RATE_LIMIT_REMAINING_HEADER = 'x-ratelimit-remaining'
13
+ RATE_LIMIT_RESET_AFTER_HEADER = 'x-ratelimit-reset-after'
14
+ RATE_LIMIT_GLOBAL_HEADER = 'x-ratelimit-global'
15
+ RATE_LIMIT_BUCKET_HEADER = 'x-ratelimit-bucket'
16
+ RATE_LIMIT_LIMIT_HEADER = 'x-ratelimit-limit'
17
+
18
+ # Major parameters that affect rate limit buckets
19
+ MAJOR_PARAMS = %w[channels guilds webhooks].freeze
20
+
21
+ # @return [Configuration] Configuration instance
22
+ attr_reader :config
23
+
24
+ # @return [Logger] Logger instance
25
+ attr_reader :logger
26
+
27
+ # @return [InvalidRequestBucket] Invalid request bucket
28
+ attr_reader :invalid_bucket
29
+
30
+ # @return [Hash<String, RequestQueue>] Request queues
31
+ attr_reader :queues
32
+
33
+ # @return [Hash<String, Hash>] Rate limited paths
34
+ attr_reader :rate_limited_paths
35
+
36
+ # @return [Boolean] Whether globally rate limited
37
+ attr_accessor :globally_rate_limited
38
+
39
+ # @return [Boolean] Whether processing rate limited paths
40
+ attr_accessor :processing_rate_limited_paths
41
+
42
+ # @return [Integer] Delay before deleting empty queue (ms)
43
+ attr_reader :delete_queue_delay
44
+
45
+ # @return [Integer] Maximum retry count
46
+ attr_reader :max_retry_count
47
+
48
+ # @return [Boolean] Whether using proxy
49
+ attr_reader :is_proxied
50
+
51
+ # @return [String] Proxy base URL
52
+ attr_reader :proxy_base_url
53
+
54
+ # @return [String] Proxy authorization
55
+ attr_reader :proxy_authorization
56
+
57
+ # Initialize the scalable REST client
58
+ # @param config [Configuration] Bot configuration
59
+ # @param logger [Logger] Logger instance
60
+ # @param proxy [Hash] Proxy configuration (base_url, authorization)
61
+ def initialize(config, logger, proxy: nil)
62
+ @config = config
63
+ @logger = logger
64
+ @invalid_bucket = InvalidRequestBucket.new(logger: logger)
65
+ @queues = {}
66
+ @rate_limited_paths = {}
67
+ @globally_rate_limited = false
68
+ @processing_rate_limited_paths = false
69
+ @delete_queue_delay = 60_000
70
+ @max_retry_count = Float::INFINITY
71
+ @mutex = Mutex.new
72
+ @internet = nil
73
+
74
+ # Proxy configuration for horizontal scaling
75
+ if proxy
76
+ @is_proxied = true
77
+ @proxy_base_url = proxy[:base_url]
78
+ @proxy_authorization = proxy[:authorization]
79
+ else
80
+ @is_proxied = false
81
+ @proxy_base_url = API_BASE
82
+ end
83
+ end
84
+
85
+ # Start the REST client
86
+ # @return [void]
87
+ def start
88
+ @internet = Async::HTTP::Internet.new
89
+ process_rate_limited_paths
90
+ end
91
+
92
+ # Stop the REST client
93
+ # @return [void]
94
+ def stop
95
+ @internet&.close
96
+ @internet = nil
97
+ end
98
+
99
+ # Make a GET request
100
+ # @param route [String] API route
101
+ # @param options [Hash] Request options
102
+ # @return [Hash] Response data
103
+ def get(route, options = {})
104
+ make_request(:get, route, options)
105
+ end
106
+
107
+ # Make a POST request
108
+ # @param route [String] API route
109
+ # @param options [Hash] Request options
110
+ # @return [Hash] Response data
111
+ def post(route, options = {})
112
+ make_request(:post, route, options)
113
+ end
114
+
115
+ # Make a PUT request
116
+ # @param route [String] API route
117
+ # @param options [Hash] Request options
118
+ # @return [Hash] Response data
119
+ def put(route, options = {})
120
+ make_request(:put, route, options)
121
+ end
122
+
123
+ # Make a PATCH request
124
+ # @param route [String] API route
125
+ # @param options [Hash] Request options
126
+ # @return [Hash] Response data
127
+ def patch(route, options = {})
128
+ make_request(:patch, route, options)
129
+ end
130
+
131
+ # Make a DELETE request
132
+ # @param route [String] API route
133
+ # @param options [Hash] Request options
134
+ # @return [Hash] Response data
135
+ def delete(route, options = {})
136
+ make_request(:delete, route, options)
137
+ end
138
+
139
+ # Simplify URL for rate limit bucket identification
140
+ # @param url [String] Full URL
141
+ # @param method [Symbol] HTTP method
142
+ # @return [String] Simplified URL for bucket
143
+ def simplify_url(url, method)
144
+ # Split URL into parts
145
+ parts = url.split('/').reject(&:empty?)
146
+
147
+ # Build simplified URL
148
+ simplified = [method.to_s.upcase]
149
+
150
+ parts.each_with_index do |part, index|
151
+ # Check if this is a major parameter (channels, guilds, webhooks)
152
+ if MAJOR_PARAMS.include?(part)
153
+ simplified << part
154
+ # Keep the ID after major params
155
+ if parts[index + 1] && parts[index + 1] =~ /^\d+$/
156
+ simplified << parts[index + 1]
157
+ end
158
+ elsif part =~ /^\d+$/
159
+ # Replace numeric IDs with 'x' unless after major param
160
+ prev = parts[index - 1]
161
+ simplified << 'x' unless MAJOR_PARAMS.include?(prev)
162
+ else
163
+ simplified << part
164
+ end
165
+ end
166
+
167
+ # Special handling for reactions
168
+ if url.include?('/reactions/')
169
+ # Simplify reactions path: /reactions/emoji/@me or /reactions/emoji/user_id
170
+ simplified = simplify_reactions_url(simplified)
171
+ end
172
+
173
+ # Special handling for messages
174
+ if url.include?('/messages/')
175
+ # Keep method in front for messages
176
+ simplified = simplify_messages_url(method, parts)
177
+ end
178
+
179
+ simplified.join('/')
180
+ end
181
+
182
+ # Check rate limits for a URL or bucket
183
+ # @param url [String] URL or bucket ID
184
+ # @param identifier [String] Queue identifier
185
+ # @return [Integer, false] Milliseconds until reset, or false if not limited
186
+ def check_rate_limits(url, identifier)
187
+ @mutex.synchronize do
188
+ # Check specific URL rate limit
189
+ limited = @rate_limited_paths["#{identifier}#{url}"]
190
+ global = @rate_limited_paths['global']
191
+ now = Time.now.to_f * 1000
192
+
193
+ if limited && now < limited[:reset_timestamp]
194
+ return limited[:reset_timestamp] - now
195
+ end
196
+
197
+ if global && now < global[:reset_timestamp]
198
+ return global[:reset_timestamp] - now
199
+ end
200
+
201
+ false
202
+ end
203
+ end
204
+
205
+ # Process rate limited paths (cleanup loop)
206
+ # @return [void]
207
+ def process_rate_limited_paths
208
+ @mutex.synchronize do
209
+ now = Time.now.to_f * 1000
210
+
211
+ @rate_limited_paths.delete_if do |key, value|
212
+ if value[:reset_timestamp] <= now
213
+ # If it was global, mark as not globally rate limited
214
+ @globally_rate_limited = false if key == 'global'
215
+ true # Delete this entry
216
+ else
217
+ false # Keep this entry
218
+ end
219
+ end
220
+
221
+ # If all paths are cleared, stop processing
222
+ if @rate_limited_paths.empty?
223
+ @processing_rate_limited_paths = false
224
+ else
225
+ @processing_rate_limited_paths = true
226
+ # Recheck in 1 second
227
+ Async { sleep(1); process_rate_limited_paths }
228
+ end
229
+ end
230
+ end
231
+
232
+ # Update token in all queues (for token refresh)
233
+ # @param old_token [String] Old token
234
+ # @param new_token [String] New token
235
+ # @return [void]
236
+ def update_token_queues(old_token, new_token)
237
+ @mutex.synchronize do
238
+ old_identifier = "Bearer #{old_token}"
239
+ new_identifier = "Bearer #{new_token}"
240
+
241
+ # Update queues
242
+ @queues.delete_if do |key, queue|
243
+ next false unless key.start_with?(old_identifier)
244
+
245
+ @queues.delete(key)
246
+ queue.identifier = new_identifier
247
+
248
+ new_key = "#{new_identifier}#{queue.url}"
249
+ existing = @queues[new_key]
250
+
251
+ if existing
252
+ # Merge queues
253
+ existing.pending.concat(queue.pending)
254
+ queue.pending.clear
255
+ queue.cleanup
256
+ true # Delete old queue
257
+ else
258
+ @queues[new_key] = queue
259
+ false # Don't delete, we moved it
260
+ end
261
+ end
262
+
263
+ # Update rate limited paths
264
+ @rate_limited_paths.delete_if do |key, path|
265
+ next false unless key.start_with?(old_identifier)
266
+
267
+ @rate_limited_paths["#{new_identifier}#{path[:url]}"] = path
268
+
269
+ if path[:bucket_id]
270
+ @rate_limited_paths["#{new_identifier}#{path[:bucket_id]}"] = path
271
+ end
272
+
273
+ true # Delete old entry
274
+ end
275
+ end
276
+ end
277
+
278
+ private
279
+
280
+ def make_request(method, route, options = {})
281
+ url = simplify_url(route, method)
282
+ identifier = options[:authorization] || "Bot #{@config.token}"
283
+
284
+ # Create queue if doesn't exist
285
+ queue = @mutex.synchronize do
286
+ @queues["#{identifier}#{url}"] ||= RequestQueue.new(
287
+ self,
288
+ url: url,
289
+ identifier: identifier,
290
+ delete_queue_delay: @delete_queue_delay
291
+ )
292
+ end
293
+
294
+ # Add request to queue
295
+ promise = Async::Condition.new
296
+
297
+ request = {
298
+ method: method,
299
+ route: route,
300
+ body: options[:body],
301
+ headers: options[:headers],
302
+ bucket_id: options[:bucket_id],
303
+ resolve: ->(result) { promise.signal(result) },
304
+ reject: ->(error) { promise.signal(error) }
305
+ }
306
+
307
+ queue.make_request(request)
308
+
309
+ # Wait for result
310
+ result = promise.wait
311
+
312
+ # Check if it's an error
313
+ raise result[:error] if result.is_a?(Hash) && result[:error]
314
+
315
+ result
316
+ end
317
+
318
+ def send_request(request)
319
+ full_url = "#{@proxy_base_url}/v#{@config.api_version}#{request[:route]}"
320
+
321
+ # Build headers
322
+ headers = build_headers(request[:headers])
323
+
324
+ # Make request
325
+ response = make_http_request(request[:method], full_url, request[:body], headers)
326
+
327
+ # Process response
328
+ process_response(response, request)
329
+ rescue => e
330
+ @logger.error('Request failed', error: e, route: request[:route])
331
+ raise
332
+ end
333
+
334
+ def make_http_request(method, url, body, headers)
335
+ body_json = body ? Oj.dump(body, mode: :compat) : nil
336
+
337
+ case method
338
+ when :get
339
+ @internet.get(url, headers)
340
+ when :post
341
+ @internet.post(url, headers, body_json)
342
+ when :put
343
+ @internet.put(url, headers, body_json)
344
+ when :patch
345
+ @internet.patch(url, headers, body_json)
346
+ when :delete
347
+ @internet.delete(url, headers)
348
+ else
349
+ raise ArgumentError, "Unknown HTTP method: #{method}"
350
+ end
351
+ end
352
+
353
+ def process_response(response, request)
354
+ status = response.status
355
+ body = response.read
356
+ data = body ? Oj.load(body) : nil
357
+
358
+ # Handle invalid request tracking
359
+ @invalid_bucket.handle_request(status)
360
+
361
+ # Process rate limit headers
362
+ bucket_id = process_headers(request[:route], response.headers, request[:identifier])
363
+
364
+ # Update queue with rate limit info
365
+ url = simplify_url(request[:route], request[:method])
366
+ queue = @queues["#{request[:identifier]}#{url}"]
367
+
368
+ if queue
369
+ queue.handle_completed_request(
370
+ max: response.headers[RATE_LIMIT_LIMIT_HEADER]&.to_i,
371
+ remaining: response.headers[RATE_LIMIT_REMAINING_HEADER]&.to_i,
372
+ interval: response.headers[RATE_LIMIT_RESET_AFTER_HEADER]&.to_f&.*(1000)
373
+ )
374
+ end
375
+
376
+ case status
377
+ when 200..299
378
+ data
379
+ when 400
380
+ raise BadRequestError.new(status, data)
381
+ when 401
382
+ raise UnauthorizedError.new(status, data)
383
+ when 403
384
+ raise ForbiddenError.new(status, data)
385
+ when 404
386
+ raise NotFoundError.new(status, data)
387
+ when 429
388
+ retry_after = data['retry_after'] || response.headers['retry-after']&.to_f
389
+ raise RateLimitedError.new(status, data, retry_after: retry_after)
390
+ when 500..599
391
+ raise ServerError.new(status, data)
392
+ else
393
+ raise APIError.new(status, data)
394
+ end
395
+ end
396
+
397
+ def process_headers(url, headers, identifier)
398
+ remaining = headers[RATE_LIMIT_REMAINING_HEADER]
399
+ retry_after = headers['Retry-After'] || headers[RATE_LIMIT_RESET_AFTER_HEADER]
400
+ reset = Time.now.to_f * 1000 + retry_after.to_f * 1000 if retry_after
401
+ global = headers[RATE_LIMIT_GLOBAL_HEADER]
402
+ bucket_id = headers[RATE_LIMIT_BUCKET_HEADER]
403
+ identifier ||= "Bot #{@config.token}"
404
+
405
+ rate_limited = false
406
+
407
+ # If no remaining, mark as rate limited
408
+ if remaining == '0'
409
+ rate_limited = true
410
+
411
+ @mutex.synchronize do
412
+ @rate_limited_paths["#{identifier}#{url}"] = {
413
+ url: url,
414
+ reset_timestamp: reset,
415
+ bucket_id: bucket_id
416
+ }
417
+
418
+ if bucket_id
419
+ @rate_limited_paths["#{identifier}#{bucket_id}"] = {
420
+ url: url,
421
+ reset_timestamp: reset,
422
+ bucket_id: bucket_id
423
+ }
424
+ end
425
+ end
426
+ end
427
+
428
+ # Handle global rate limit
429
+ if global
430
+ retry_ms = headers['retry-after'].to_f * 1000
431
+ global_reset = Time.now.to_f * 1000 + retry_ms
432
+
433
+ @globally_rate_limited = true
434
+ rate_limited = true
435
+
436
+ @mutex.synchronize do
437
+ @rate_limited_paths['global'] = {
438
+ url: 'global',
439
+ reset_timestamp: global_reset,
440
+ bucket_id: bucket_id
441
+ }
442
+ end
443
+
444
+ Async { sleep(retry_ms / 1000.0); @globally_rate_limited = false }
445
+ end
446
+
447
+ # Start processing rate limited paths if needed
448
+ if rate_limited && !@processing_rate_limited_paths
449
+ process_rate_limited_paths
450
+ end
451
+
452
+ bucket_id if rate_limited
453
+ end
454
+
455
+ def build_headers(additional = {})
456
+ base = {
457
+ 'User-Agent' => "DiscordRDA (https://github.com/juliaklee/discord_rda, #{VERSION})",
458
+ 'Content-Type' => 'application/json',
459
+ 'Accept' => 'application/json'
460
+ }
461
+
462
+ # Add authorization
463
+ if @is_proxied && @proxy_authorization
464
+ base['Authorization'] = @proxy_authorization
465
+ else
466
+ base['Authorization'] = "Bot #{@config.token}"
467
+ end
468
+
469
+ base.merge(additional || {})
470
+ end
471
+
472
+ def simplify_reactions_url(parts)
473
+ # Convert reactions/emoji to reactions/x
474
+ parts.map { |p| p =~ /^[\w-]+$/ && p.length > 10 ? 'x' : p }
475
+ end
476
+
477
+ def simplify_messages_url(method, parts)
478
+ result = [method.to_s.upcase]
479
+
480
+ parts.each_with_index do |part, index|
481
+ if MAJOR_PARAMS.include?(part)
482
+ result << part
483
+ # Keep ID after major param
484
+ result << parts[index + 1] if parts[index + 1] && parts[index + 1] =~ /^\d+$/
485
+ elsif part == 'messages' && parts[index + 1] =~ /^\d+$/
486
+ result << part << 'x'
487
+ elsif part =~ /^\d+$/
488
+ prev = parts[index - 1]
489
+ result << 'x' unless MAJOR_PARAMS.include?(prev)
490
+ else
491
+ result << part
492
+ end
493
+ end
494
+
495
+ result.uniq
496
+ end
497
+
498
+ # Error classes
499
+ class APIError < StandardError
500
+ attr_reader :status, :data
501
+
502
+ def initialize(status, data)
503
+ @status = status
504
+ @data = data || {}
505
+ message = @data['message'] || 'Unknown error'
506
+ super("API Error #{status}: #{message}")
507
+ end
508
+ end
509
+
510
+ class BadRequestError < APIError; end
511
+ class UnauthorizedError < APIError; end
512
+ class ForbiddenError < APIError; end
513
+ class NotFoundError < APIError; end
514
+
515
+ class RateLimitedError < APIError
516
+ attr_reader :retry_after
517
+
518
+ def initialize(status, data, retry_after: nil)
519
+ super(status, data)
520
+ @retry_after = retry_after || data['retry_after'] || 1.0
521
+ end
522
+ end
523
+
524
+ class ServerError < APIError; end
525
+ end
526
+ end