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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ # Redis-backed cache store.
5
+ # Provides distributed caching with Redis.
6
+ #
7
+ class RedisStore < CacheStore
8
+ # @return [Redis] Redis client
9
+ attr_reader :redis
10
+
11
+ # @return [String] Key prefix
12
+ attr_reader :prefix
13
+
14
+ # @return [Integer] Default TTL
15
+ attr_reader :default_ttl
16
+
17
+ # Initialize Redis store
18
+ # @param redis [Redis] Redis client or connection options
19
+ # @param prefix [String] Key prefix
20
+ # @param default_ttl [Integer] Default TTL in seconds
21
+ def initialize(redis: nil, prefix: 'discord_rda:', default_ttl: 3600)
22
+ @redis = redis.is_a?(Redis) ? redis : Redis.new(redis || {})
23
+ @prefix = prefix
24
+ @default_ttl = default_ttl
25
+ end
26
+
27
+ # Get a value from cache
28
+ # @param key [String] Cache key
29
+ # @return [Object, nil] Cached value or nil
30
+ def get(key)
31
+ value = @redis.get(prefixed_key(key))
32
+ return nil unless value
33
+
34
+ Marshal.load(value)
35
+ rescue => e
36
+ nil
37
+ end
38
+
39
+ # Set a value in cache
40
+ # @param key [String] Cache key
41
+ # @param value [Object] Value to cache
42
+ # @param ttl [Integer] Time to live in seconds
43
+ # @return [void]
44
+ def set(key, value, ttl: nil)
45
+ serialized = Marshal.dump(value)
46
+ ttl ||= @default_ttl
47
+
48
+ if ttl
49
+ @redis.setex(prefixed_key(key), ttl, serialized)
50
+ else
51
+ @redis.set(prefixed_key(key), serialized)
52
+ end
53
+ end
54
+
55
+ # Delete a value from cache
56
+ # @param key [String] Cache key
57
+ # @return [void]
58
+ def delete(key)
59
+ @redis.del(prefixed_key(key))
60
+ end
61
+
62
+ # Check if key exists
63
+ # @param key [String] Cache key
64
+ # @return [Boolean] True if exists
65
+ def exist?(key)
66
+ @redis.exists?(prefixed_key(key))
67
+ end
68
+
69
+ # Clear all cached values (matching prefix)
70
+ # @return [void]
71
+ def clear
72
+ keys = @redis.keys("#{@prefix}*")
73
+ @redis.del(*keys) unless keys.empty?
74
+ end
75
+
76
+ # Get multiple values
77
+ # @param keys [Array<String>] Cache keys
78
+ # @return [Hash] Key-value pairs
79
+ def mget(keys)
80
+ prefixed = keys.map { |k| prefixed_key(k) }
81
+ values = @redis.mget(prefixed)
82
+
83
+ keys.zip(values).to_h do |k, v|
84
+ [k, v ? Marshal.load(v) : nil]
85
+ end
86
+ rescue
87
+ {}
88
+ end
89
+
90
+ # Set multiple values
91
+ # @param pairs [Hash] Key-value pairs
92
+ # @param ttl [Integer] Time to live
93
+ # @return [void]
94
+ def mset(pairs, ttl: nil)
95
+ ttl ||= @default_ttl
96
+
97
+ @redis.multi do |pipeline|
98
+ pairs.each do |k, v|
99
+ serialized = Marshal.dump(v)
100
+ if ttl
101
+ pipeline.setex(prefixed_key(k), ttl, serialized)
102
+ else
103
+ pipeline.set(prefixed_key(k), serialized)
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ # Get keys matching a pattern using SCAN (non-blocking)
110
+ # @param pattern [String, Regexp] Pattern to match
111
+ # @return [Array<String>] Matching keys (without prefix)
112
+ def keys(pattern)
113
+ glob_pattern = pattern.is_a?(Regexp) ? "#{@prefix}*" : "#{@prefix}#{pattern}"
114
+ keys = []
115
+
116
+ # Use SCAN to iterate without blocking Redis
117
+ cursor = '0'
118
+ loop do
119
+ cursor, results = @redis.scan(cursor, match: glob_pattern, count: 100)
120
+ keys.concat(results)
121
+ break if cursor == '0'
122
+ end
123
+
124
+ # Strip prefix from keys
125
+ keys.map { |k| k.delete_prefix(@prefix) }
126
+ rescue => e
127
+ []
128
+ end
129
+
130
+ private
131
+
132
+ def prefixed_key(key)
133
+ "#{@prefix}#{key}"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ # Cache store interface.
5
+ # Implementations must provide get, set, delete, and clear methods.
6
+ #
7
+ class CacheStore
8
+ # Get a value from cache
9
+ # @param key [String] Cache key
10
+ # @return [Object, nil] Cached value or nil
11
+ def get(key)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ # Set a value in cache
16
+ # @param key [String] Cache key
17
+ # @param value [Object] Value to cache
18
+ # @param ttl [Integer] Time to live in seconds
19
+ # @return [void]
20
+ def set(key, value, ttl: nil)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Delete a value from cache
25
+ # @param key [String] Cache key
26
+ # @return [void]
27
+ def delete(key)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ # Check if key exists
32
+ # @param key [String] Cache key
33
+ # @return [Boolean] True if exists
34
+ def exist?(key)
35
+ !get(key).nil?
36
+ end
37
+
38
+ # Clear all cached values
39
+ # @return [void]
40
+ def clear
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # Get multiple values
45
+ # @param keys [Array<String>] Cache keys
46
+ # @return [Hash] Key-value pairs
47
+ def mget(keys)
48
+ keys.to_h { |k| [k, get(k)] }
49
+ end
50
+
51
+ # Get keys matching a pattern
52
+ # @param pattern [String, Regexp] Pattern to match
53
+ # @return [Array<String>] Matching keys
54
+ def keys(pattern)
55
+ raise NotImplementedError
56
+ end
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async/websocket'
4
+ require 'async/http/internet'
5
+ require 'async/http/endpoint'
6
+ require 'async/io/stream'
7
+ require 'zlib'
8
+
9
+ module DiscordRDA
10
+ # WebSocket client for Discord Gateway.
11
+ # Handles connection, heartbeat, resume, and message dispatch.
12
+ #
13
+ # @example Basic usage
14
+ # gateway = GatewayClient.new(config, event_bus)
15
+ # gateway.connect
16
+ # gateway.run
17
+ #
18
+ class GatewayClient
19
+ # Gateway versions
20
+ DEFAULT_VERSION = 10
21
+
22
+ # Gateway encoding
23
+ ENCODING = 'json'
24
+
25
+ # Gateway opcodes
26
+ OPCODES = {
27
+ dispatch: 0,
28
+ heartbeat: 1,
29
+ identify: 2,
30
+ presence_update: 3,
31
+ voice_state_update: 4,
32
+ resume: 6,
33
+ reconnect: 7,
34
+ request_guild_members: 8,
35
+ invalid_session: 9,
36
+ hello: 10,
37
+ heartbeat_ack: 11
38
+ }.freeze
39
+
40
+ # @return [Configuration] Configuration instance
41
+ attr_reader :config
42
+
43
+ # @return [EventBus] Event bus for dispatching events
44
+ attr_reader :event_bus
45
+
46
+ # @return [Logger] Logger instance
47
+ attr_reader :logger
48
+
49
+ # @return [Integer] Current sequence number
50
+ attr_reader :sequence
51
+
52
+ # @return [String] Session ID for resuming
53
+ attr_reader :session_id
54
+
55
+ # @return [Boolean] Whether connected
56
+ attr_reader :connected
57
+
58
+ # Initialize the gateway client
59
+ # @param config [Configuration] Bot configuration
60
+ # @param event_bus [EventBus] Event bus instance
61
+ # @param logger [Logger] Logger instance
62
+ # @param shard_id [Integer] Shard ID
63
+ # @param shard_count [Integer] Total shard count
64
+ def initialize(config, event_bus, logger, shard_id: 0, shard_count: 1)
65
+ @config = config
66
+ @event_bus = event_bus
67
+ @logger = logger
68
+ @shard_id = shard_id
69
+ @shard_count = shard_count
70
+ @sequence = 0
71
+ @session_id = nil
72
+ @connected = false
73
+ @heartbeat_interval = nil
74
+ @heartbeat_task = nil
75
+ @websocket = nil
76
+ @zlib = nil
77
+ @buffer = +'')
78
+ @last_heartbeat_ack = Time.now
79
+ @resume_gateway_url = nil
80
+ end
81
+
82
+ # Connect to the Discord Gateway
83
+ # @return [Async::Task] Connection task
84
+ def connect
85
+ Async do
86
+ gateway_url = @resume_gateway_url || fetch_gateway_url
87
+ endpoint = build_endpoint(gateway_url)
88
+
89
+ @logger&.info('Connecting to Gateway', shard: @shard_id, url: gateway_url)
90
+
91
+ @zlib = Zlib::Inflate.new(15 + 32) if @config.gateway_compression == :zlib_stream
92
+ @buffer = +''
93
+
94
+ begin
95
+ Async::WebSocket::Client.connect(endpoint) do |websocket|
96
+ @websocket = websocket
97
+ @connected = true
98
+ @logger&.info('Gateway connected', shard: @shard_id)
99
+
100
+ handle_messages
101
+ end
102
+ rescue => e
103
+ @logger&.error('Gateway connection error', error: e, shard: @shard_id)
104
+ @connected = false
105
+ raise
106
+ end
107
+ end
108
+ end
109
+
110
+ # Run the gateway event loop
111
+ # @return [void]
112
+ def run
113
+ connect.wait
114
+ end
115
+
116
+ # Disconnect from the Gateway
117
+ # @return [void]
118
+ def disconnect
119
+ @connected = false
120
+ @heartbeat_task&.stop
121
+ @websocket&.close
122
+ @zlib&.close
123
+ @logger&.info('Gateway disconnected', shard: @shard_id)
124
+ end
125
+
126
+ # Send an identify payload
127
+ # @return [void]
128
+ def identify
129
+ payload = {
130
+ op: OPCODES[:identify],
131
+ d: {
132
+ token: @config.token,
133
+ properties: {
134
+ os: 'linux',
135
+ browser: 'discord_rda',
136
+ device: 'discord_rda'
137
+ },
138
+ compress: @config.gateway_compression == :zlib_stream,
139
+ large_threshold: 250,
140
+ shard: [@shard_id, @shard_count],
141
+ intents: @config.intents_bitmask
142
+ }
143
+ }
144
+
145
+ send_payload(payload)
146
+ @logger&.info('Sent identify', shard: @shard_id)
147
+ end
148
+
149
+ # Send a resume payload
150
+ # @return [void]
151
+ def resume
152
+ return unless @session_id && @sequence > 0
153
+
154
+ payload = {
155
+ op: OPCODES[:resume],
156
+ d: {
157
+ token: @config.token,
158
+ session_id: @session_id,
159
+ seq: @sequence
160
+ }
161
+ }
162
+
163
+ send_payload(payload)
164
+ @logger&.info('Sent resume', shard: @shard_id, session: @session_id, seq: @sequence)
165
+ end
166
+
167
+ # Update presence
168
+ # @param status [String] online, idle, dnd, invisible
169
+ # @param activity [Hash] Activity data
170
+ # @param afk [Boolean] Whether AFK
171
+ # @return [void]
172
+ def update_presence(status: 'online', activity: nil, afk: false)
173
+ payload = {
174
+ op: OPCODES[:presence_update],
175
+ d: {
176
+ since: afk ? Time.now.to_i * 1000 : nil,
177
+ activities: activity ? [activity] : [],
178
+ status: status,
179
+ afk: afk
180
+ }
181
+ }
182
+
183
+ send_payload(payload)
184
+ end
185
+
186
+ # Request guild members (chunking)
187
+ # @param guild_id [String] Guild ID
188
+ # @param query [String] Query string
189
+ # @param limit [Integer] Member limit
190
+ # @param presences [Boolean] Include presences
191
+ # @param user_ids [Array<String>] Specific user IDs
192
+ # @param nonce [String] Nonce for response
193
+ # @return [void]
194
+ def request_guild_members(guild_id, query: '', limit: 0, presences: false, user_ids: nil, nonce: nil)
195
+ payload = {
196
+ op: OPCODES[:request_guild_members],
197
+ d: {
198
+ guild_id: guild_id,
199
+ query: query,
200
+ limit: limit,
201
+ presences: presences,
202
+ user_ids: user_ids,
203
+ nonce: nonce
204
+ }.compact
205
+ }
206
+
207
+ send_payload(payload)
208
+ end
209
+
210
+ private
211
+
212
+ def fetch_gateway_url
213
+ # In production, fetch from /gateway/bot endpoint
214
+ # For now, use hardcoded URL
215
+ "wss://gateway.discord.gg/?v=#{DEFAULT_VERSION}&encoding=#{ENCODING}"
216
+ end
217
+
218
+ def build_endpoint(url)
219
+ Async::HTTP::Endpoint.parse(url)
220
+ end
221
+
222
+ def handle_messages
223
+ while @connected && (message = @websocket.read)
224
+ process_message(message)
225
+ end
226
+ rescue Async::WebSocket::ConnectionClosed
227
+ @logger&.warn('Gateway connection closed', shard: @shard_id)
228
+ handle_disconnect
229
+ end
230
+
231
+ def process_message(message)
232
+ data = decompress_if_needed(message)
233
+ return unless data
234
+
235
+ payload = Oj.load(data)
236
+ handle_payload(payload)
237
+ rescue Oj::ParseError => e
238
+ @logger&.error('Failed to parse Gateway message', error: e, shard: @shard_id)
239
+ end
240
+
241
+ def decompress_if_needed(message)
242
+ if @config.gateway_compression == :zlib_stream && @zlib
243
+ chunk = message.to_str
244
+ @buffer << chunk
245
+
246
+ # Check for zlib suffix
247
+ if chunk.byteslice(-4, 4) == "\x00\x00\xff\xff"
248
+ decompressed = @zlib.inflate(@buffer)
249
+ @buffer = +''
250
+ decompressed
251
+ else
252
+ nil
253
+ end
254
+ else
255
+ message.to_str
256
+ end
257
+ end
258
+
259
+ def handle_payload(payload)
260
+ op = payload['op']
261
+ data = payload['d']
262
+ seq = payload['s']
263
+ event_type = payload['t']
264
+
265
+ # Update sequence number
266
+ @sequence = seq if seq
267
+
268
+ case op
269
+ when OPCODES[:dispatch]
270
+ handle_dispatch(event_type, data)
271
+ when OPCODES[:hello]
272
+ handle_hello(data)
273
+ when OPCODES[:heartbeat_ack]
274
+ handle_heartbeat_ack
275
+ when OPCODES[:reconnect]
276
+ handle_reconnect
277
+ when OPCODES[:invalid_session]
278
+ handle_invalid_session(data)
279
+ else
280
+ @logger&.debug('Unhandled Gateway opcode', op: op, shard: @shard_id)
281
+ end
282
+ end
283
+
284
+ def handle_dispatch(event_type, data)
285
+ return unless event_type
286
+
287
+ # Store session ID for resume
288
+ if event_type == 'READY'
289
+ @session_id = data['session_id']
290
+ @resume_gateway_url = data['resume_gateway_url']
291
+ @logger&.info('Received READY', shard: @shard_id, session: @session_id)
292
+ elsif event_type == 'RESUMED'
293
+ @logger&.info('Session resumed', shard: @shard_id)
294
+ end
295
+
296
+ # Create and dispatch event
297
+ event = EventFactory.create(event_type, data, @shard_id)
298
+ @event_bus&.publish(event_type, event)
299
+ end
300
+
301
+ def handle_hello(data)
302
+ @heartbeat_interval = data['heartbeat_interval']
303
+ @logger&.info('Received hello', interval: @heartbeat_interval, shard: @shard_id)
304
+
305
+ # Start heartbeat task
306
+ start_heartbeat
307
+
308
+ # Identify or resume
309
+ if @config.enable_resume && @session_id && @sequence > 0
310
+ resume
311
+ else
312
+ identify
313
+ end
314
+ end
315
+
316
+ def handle_heartbeat_ack
317
+ @last_heartbeat_ack = Time.now
318
+ @logger&.debug('Heartbeat acknowledged', shard: @shard_id)
319
+ end
320
+
321
+ def handle_reconnect
322
+ @logger&.info('Received reconnect request', shard: @shard_id)
323
+ disconnect
324
+ sleep(@config.initial_reconnect_delay)
325
+ connect
326
+ end
327
+
328
+ def handle_invalid_session(resumable)
329
+ @logger&.warn('Invalid session', resumable: resumable, shard: @shard_id)
330
+
331
+ if resumable
332
+ sleep(1..5).to_a.sample
333
+ resume
334
+ else
335
+ @session_id = nil
336
+ @sequence = 0
337
+ sleep(1..5).to_a.sample
338
+ identify
339
+ end
340
+ end
341
+
342
+ def handle_disconnect
343
+ @connected = false
344
+ @heartbeat_task&.stop
345
+
346
+ # Attempt to resume if enabled
347
+ if @config.enable_resume && @session_id
348
+ @logger&.info('Attempting to resume', shard: @shard_id)
349
+ sleep(@config.initial_reconnect_delay)
350
+ connect
351
+ end
352
+ end
353
+
354
+ def start_heartbeat
355
+ @heartbeat_task = Async do
356
+ loop do
357
+ sleep(@heartbeat_interval * @config.heartbeat_interval_buffer / 1000.0)
358
+ send_heartbeat
359
+ end
360
+ end
361
+ end
362
+
363
+ def send_heartbeat
364
+ if Time.now - @last_heartbeat_ack > (@heartbeat_interval * 2 / 1000.0)
365
+ @logger&.warn('Heartbeat timeout, reconnecting', shard: @shard_id)
366
+ handle_disconnect
367
+ return
368
+ end
369
+
370
+ payload = { op: OPCODES[:heartbeat], d: @sequence }
371
+ send_payload(payload)
372
+ @logger&.debug('Sent heartbeat', seq: @sequence, shard: @shard_id)
373
+ end
374
+
375
+ def send_payload(payload)
376
+ return unless @websocket && @connected
377
+
378
+ json = Oj.dump(payload, mode: :compat)
379
+ @websocket.send(json)
380
+ @websocket.flush
381
+ end
382
+ end
383
+ end