discord_rda 0.1.3 → 0.2.0

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.
@@ -54,3 +54,5 @@ module DiscordRDA
54
54
  def keys(pattern)
55
55
  raise NotImplementedError
56
56
  end
57
+ end
58
+ end
@@ -3,7 +3,7 @@
3
3
  require 'async/websocket'
4
4
  require 'async/http/internet'
5
5
  require 'async/http/endpoint'
6
- require 'async/io/stream'
6
+ require 'io/stream'
7
7
  require 'zlib'
8
8
 
9
9
  module DiscordRDA
@@ -52,6 +52,9 @@ module DiscordRDA
52
52
  # @return [String] Session ID for resuming
53
53
  attr_reader :session_id
54
54
 
55
+ # @return [String, nil] Resume gateway URL
56
+ attr_reader :resume_gateway_url
57
+
55
58
  # @return [Boolean] Whether connected
56
59
  attr_reader :connected
57
60
 
@@ -74,7 +77,7 @@ module DiscordRDA
74
77
  @heartbeat_task = nil
75
78
  @websocket = nil
76
79
  @zlib = nil
77
- @buffer = +'')
80
+ @buffer = +''
78
81
  @last_heartbeat_ack = Time.now
79
82
  @resume_gateway_url = nil
80
83
  end
@@ -207,6 +210,13 @@ module DiscordRDA
207
210
  send_payload(payload)
208
211
  end
209
212
 
213
+ def restore_session_state(session_id:, sequence:, resume_gateway_url: nil)
214
+ @session_id = session_id
215
+ @sequence = sequence.to_i
216
+ @resume_gateway_url = resume_gateway_url if resume_gateway_url
217
+ @logger&.info('Restored gateway session state', shard: @shard_id, session: @session_id, seq: @sequence)
218
+ end
219
+
210
220
  private
211
221
 
212
222
  def fetch_gateway_url
@@ -4,6 +4,7 @@ require 'async/http/internet'
4
4
  require 'async/http/endpoint'
5
5
  require 'cgi'
6
6
  require 'net/http/post/multipart'
7
+ require 'oj'
7
8
 
8
9
  module DiscordRDA
9
10
  # HTTP client for Discord REST API.
@@ -264,7 +265,7 @@ module DiscordRDA
264
265
 
265
266
  def handle_response(response)
266
267
  body = response.read
267
- data = body ? Oj.load(body) : nil
268
+ data = parse_response_body(body)
268
269
 
269
270
  case response.status
270
271
  when 200..299
@@ -286,6 +287,14 @@ module DiscordRDA
286
287
  end
287
288
  end
288
289
 
290
+ def parse_response_body(body)
291
+ return nil if body.nil? || body.empty?
292
+
293
+ Oj.load(body)
294
+ rescue Oj::ParseError
295
+ body
296
+ end
297
+
289
298
  # REST API Errors
290
299
  class APIError < StandardError
291
300
  attr_reader :status, :data, :code, :message
@@ -130,7 +130,11 @@ module DiscordRDA
130
130
 
131
131
  def handle_response(response)
132
132
  body = response.read
133
- data = body ? Oj.load(body) : nil
133
+ data = if body.nil? || body.empty?
134
+ nil
135
+ else
136
+ Oj.load(body)
137
+ end
134
138
 
135
139
  case response.status
136
140
  when 200..299
@@ -30,10 +30,11 @@ module DiscordRDA
30
30
  # @param config [Configuration] Bot configuration
31
31
  # @param event_bus [EventBus] Event bus instance
32
32
  # @param logger [Logger] Logger instance
33
- def initialize(config, event_bus, logger)
33
+ def initialize(config, event_bus, logger, gateway_state: {})
34
34
  @config = config
35
35
  @event_bus = event_bus
36
36
  @logger = logger
37
+ @gateway_state = gateway_state || {}
37
38
  @shard_count = nil
38
39
  @shards = []
39
40
  @total_guilds = nil
@@ -191,6 +192,14 @@ module DiscordRDA
191
192
  shard_count: shard_count
192
193
  )
193
194
 
195
+ if (state = @gateway_state[shard_id] || @gateway_state[shard_id.to_s])
196
+ gateway.restore_session_state(
197
+ session_id: state['session_id'] || state[:session_id],
198
+ sequence: state['sequence'] || state[:sequence],
199
+ resume_gateway_url: state['resume_gateway_url'] || state[:resume_gateway_url]
200
+ )
201
+ end
202
+
194
203
  @mutex.synchronize { @shards << gateway }
195
204
 
196
205
  # Start gateway in background
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'yaml'
5
+
3
6
  module DiscordRDA
4
7
  # Immutable configuration for DiscordRDA.
5
8
  # Uses frozen hash to prevent mutation after initialization.
@@ -29,7 +32,13 @@ module DiscordRDA
29
32
  compression_threshold: 1024,
30
33
  intents: [:guilds],
31
34
  log_level: :info,
32
- log_format: :structured
35
+ log_format: :structured,
36
+ log_output: :stdout,
37
+ log_file_path: nil,
38
+ log_rotate_age: 7,
39
+ log_rotate_size: 10_485_760,
40
+ trace_enabled: false,
41
+ error_tracking: false
33
42
  }.freeze
34
43
 
35
44
  # Valid intents mapping
@@ -108,6 +117,18 @@ module DiscordRDA
108
117
  # @return [Symbol] Log format (:simple, :structured)
109
118
  attr_reader :log_format
110
119
 
120
+ attr_reader :log_output
121
+
122
+ attr_reader :log_file_path
123
+
124
+ attr_reader :log_rotate_age
125
+
126
+ attr_reader :log_rotate_size
127
+
128
+ attr_reader :trace_enabled
129
+
130
+ attr_reader :error_tracking
131
+
111
132
  # Create a new configuration
112
133
  # @param options [Hash] Configuration options
113
134
  def initialize(options = {})
@@ -133,10 +154,20 @@ module DiscordRDA
133
154
  @intents = normalize_intents(config[:intents])
134
155
  @log_level = config[:log_level].to_sym
135
156
  @log_format = config[:log_format].to_sym
157
+ @log_output = config[:log_output].is_a?(Symbol) ? config[:log_output] : config[:log_output].to_s
158
+ @log_file_path = config[:log_file_path]
159
+ @log_rotate_age = config[:log_rotate_age].to_i
160
+ @log_rotate_size = config[:log_rotate_size].to_i
161
+ @trace_enabled = !!config[:trace_enabled]
162
+ @error_tracking = !!config[:error_tracking]
136
163
 
137
164
  freeze
138
165
  end
139
166
 
167
+ def self.load(path, overrides: {})
168
+ new(load_file(path).merge(overrides))
169
+ end
170
+
140
171
  # Calculate the intents bitmask for Gateway identify
141
172
  # @return [Integer] Intents bitmask
142
173
  def intents_bitmask
@@ -170,12 +201,44 @@ module DiscordRDA
170
201
  compression_threshold: @compression_threshold,
171
202
  intents: @intents,
172
203
  log_level: @log_level,
173
- log_format: @log_format
204
+ log_format: @log_format,
205
+ log_output: @log_output,
206
+ log_file_path: @log_file_path,
207
+ log_rotate_age: @log_rotate_age,
208
+ log_rotate_size: @log_rotate_size,
209
+ trace_enabled: @trace_enabled,
210
+ error_tracking: @error_tracking
174
211
  }
175
212
  end
176
213
 
177
214
  private
178
215
 
216
+ def self.load_file(path)
217
+ content = File.read(path)
218
+
219
+ case File.extname(path).downcase
220
+ when '.json'
221
+ symbolize_keys(JSON.parse(content))
222
+ when '.yml', '.yaml'
223
+ symbolize_keys(YAML.safe_load(content, permitted_classes: [Symbol], aliases: true) || {})
224
+ else
225
+ raise ArgumentError, "Unsupported configuration file format: #{path}"
226
+ end
227
+ end
228
+
229
+ def self.symbolize_keys(value)
230
+ case value
231
+ when Hash
232
+ value.each_with_object({}) do |(key, nested_value), hash|
233
+ hash[key.to_sym] = symbolize_keys(nested_value)
234
+ end
235
+ when Array
236
+ value.map { |item| symbolize_keys(item) }
237
+ else
238
+ value
239
+ end
240
+ end
241
+
179
242
  def normalize_shards(shards)
180
243
  return [:auto] if shards == :auto
181
244
  return shards if shards.is_a?(Array) && shards.all? { |s| s.is_a?(Array) }
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ class ErrorTracker
5
+ attr_reader :enabled
6
+
7
+ def initialize(enabled: false, logger: nil)
8
+ @enabled = enabled
9
+ @logger = logger
10
+ end
11
+
12
+ def capture(error, **context)
13
+ return unless enabled
14
+
15
+ if defined?(::Sentry)
16
+ ::Sentry.capture_exception(error, extra: context)
17
+ else
18
+ @logger&.error('Captured error', error: error, **context)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require 'tempfile'
6
+ require 'timeout'
7
+
8
+ module DiscordRDA
9
+ class ExecutionSupervisor
10
+ DEFAULT_POLICY = {
11
+ timeout_seconds: 15,
12
+ max_concurrency: 8,
13
+ failure_threshold: 5,
14
+ cooldown_seconds: 60
15
+ }.freeze
16
+
17
+ class TimeoutError < StandardError; end
18
+ class ConcurrencyLimitError < StandardError; end
19
+ class CircuitOpenError < StandardError; end
20
+ class IsolatedExecutionError < StandardError; end
21
+
22
+ attr_reader :logger
23
+
24
+ def initialize(logger: nil)
25
+ @logger = logger
26
+ @states = {}
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ def execute(key, policy: {}, &block)
31
+ merged = DEFAULT_POLICY.merge(policy || {})
32
+ state = state_for(key)
33
+
34
+ raise CircuitOpenError, "Circuit open for #{key}" if circuit_open?(state)
35
+ acquire_slot!(key, state, merged)
36
+
37
+ begin
38
+ result = ::Timeout.timeout(merged[:timeout_seconds]) { block.call }
39
+ record_success(state)
40
+ result
41
+ rescue ::Timeout::Error => e
42
+ record_failure(state)
43
+ raise TimeoutError, e.message
44
+ rescue StandardError
45
+ record_failure(state)
46
+ raise
47
+ ensure
48
+ release_slot(state)
49
+ end
50
+ end
51
+
52
+ def run_isolated(ruby_code:, timeout_seconds: 15, memory_limit_mb: nil, env: {})
53
+ Tempfile.create(['discord_rda_isolated', '.rb']) do |file|
54
+ file.write(build_isolated_runner(ruby_code, memory_limit_mb))
55
+ file.flush
56
+
57
+ stdout, stderr, status = spawn_with_timeout(
58
+ [RbConfig.ruby, file.path],
59
+ timeout_seconds: timeout_seconds,
60
+ env: env
61
+ )
62
+
63
+ raise IsolatedExecutionError, stderr unless status.success?
64
+
65
+ stdout
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def state_for(key)
72
+ @mutex.synchronize do
73
+ @states[key] ||= {
74
+ running: 0,
75
+ failures: 0,
76
+ opened_at: nil,
77
+ failure_threshold: DEFAULT_POLICY[:failure_threshold],
78
+ cooldown_seconds: DEFAULT_POLICY[:cooldown_seconds]
79
+ }
80
+ end
81
+ end
82
+
83
+ def circuit_open?(state)
84
+ @mutex.synchronize do
85
+ return false unless state[:opened_at]
86
+
87
+ if Time.now.to_f - state[:opened_at] >= state[:cooldown_seconds]
88
+ state[:opened_at] = nil
89
+ state[:failures] = 0
90
+ false
91
+ else
92
+ true
93
+ end
94
+ end
95
+ end
96
+
97
+ def acquire_slot!(key, state, policy)
98
+ @mutex.synchronize do
99
+ state[:failure_threshold] = policy[:failure_threshold]
100
+ state[:cooldown_seconds] = policy[:cooldown_seconds]
101
+
102
+ if state[:running] >= policy[:max_concurrency]
103
+ raise ConcurrencyLimitError, "Concurrency limit reached for #{key}"
104
+ end
105
+
106
+ state[:running] += 1
107
+ end
108
+ end
109
+
110
+ def release_slot(state)
111
+ @mutex.synchronize do
112
+ state[:running] -= 1 if state[:running].positive?
113
+ end
114
+ end
115
+
116
+ def record_success(state)
117
+ @mutex.synchronize do
118
+ state[:failures] = 0
119
+ state[:opened_at] = nil
120
+ end
121
+ end
122
+
123
+ def record_failure(state)
124
+ @mutex.synchronize do
125
+ state[:failures] += 1
126
+ if state[:failures] >= state[:failure_threshold]
127
+ state[:opened_at] = Time.now.to_f
128
+ end
129
+ end
130
+ end
131
+
132
+ def spawn_with_timeout(command, timeout_seconds:, env:)
133
+ stdout_r, stdout_w = IO.pipe
134
+ stderr_r, stderr_w = IO.pipe
135
+
136
+ pid = Process.spawn(env, *command, out: stdout_w, err: stderr_w)
137
+ stdout_w.close
138
+ stderr_w.close
139
+
140
+ timed_out = false
141
+ begin
142
+ ::Timeout.timeout(timeout_seconds) { Process.wait(pid) }
143
+ rescue ::Timeout::Error
144
+ timed_out = true
145
+ Process.kill('KILL', pid) rescue nil
146
+ Process.wait(pid) rescue nil
147
+ end
148
+
149
+ stdout = stdout_r.read
150
+ stderr = stderr_r.read
151
+ stdout_r.close
152
+ stderr_r.close
153
+
154
+ if timed_out
155
+ raise TimeoutError, "Isolated execution timed out after #{timeout_seconds}s"
156
+ end
157
+
158
+ [stdout, stderr, $CHILD_STATUS]
159
+ end
160
+
161
+ def build_isolated_runner(ruby_code, memory_limit_mb)
162
+ limit_code = if memory_limit_mb
163
+ "Process.setrlimit(:AS, #{memory_limit_mb.to_i} * 1024 * 1024)\n"
164
+ else
165
+ ''
166
+ end
167
+
168
+ <<~RUBY
169
+ # frozen_string_literal: true
170
+ #{limit_code}begin
171
+ #{ruby_code}
172
+ rescue StandardError => e
173
+ warn("#{e.class}: #{e.message}")
174
+ warn(e.backtrace.join("\\n")) if e.backtrace
175
+ exit(1)
176
+ end
177
+ RUBY
178
+ end
179
+ end
180
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'logger'
4
4
  require 'json'
5
+ require 'fileutils'
5
6
 
6
7
  module DiscordRDA
7
8
  # Structured logging for DiscordRDA.
@@ -35,10 +36,10 @@ module DiscordRDA
35
36
  # @param level [Symbol] Log level
36
37
  # @param format [Symbol] Log format
37
38
  # @param output [IO] Output destination (default: STDOUT)
38
- def initialize(level: :info, format: :structured, output: STDOUT)
39
+ def initialize(level: :info, format: :structured, output: STDOUT, file_path: nil, rotate_age: 7, rotate_size: 10_485_760)
39
40
  @level = level.to_sym
40
41
  @format = format.to_sym
41
- @output = output
42
+ @output = build_output(output, file_path, rotate_age, rotate_size)
42
43
  @mutex = Mutex.new
43
44
  end
44
45
 
@@ -102,11 +103,24 @@ module DiscordRDA
102
103
 
103
104
  private
104
105
 
106
+ def build_output(output, file_path, rotate_age, rotate_size)
107
+ return output unless file_path
108
+
109
+ FileUtils.mkdir_p(File.dirname(file_path))
110
+ ::Logger::LogDevice.new(file_path, shift_age: rotate_age, shift_size: rotate_size)
111
+ end
112
+
105
113
  def log(level, message, context)
106
114
  return unless level_enabled?(level)
107
115
 
108
116
  entry = build_log_entry(level, message, context)
109
- @mutex.synchronize { @output.puts(entry) }
117
+ @mutex.synchronize do
118
+ if @output.respond_to?(:puts)
119
+ @output.puts(entry)
120
+ else
121
+ @output.write("#{entry}\n")
122
+ end
123
+ end
110
124
  end
111
125
 
112
126
  def build_log_entry(level, message, context)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require 'securerandom'
6
+ require 'tmpdir'
7
+
8
+ module DiscordRDA
9
+ class RestartManager
10
+ STATE_ENV = 'DISCORD_RDA_RESTART_STATE_PATH'
11
+
12
+ attr_reader :logger
13
+
14
+ def initialize(logger:)
15
+ @logger = logger
16
+ @bot = nil
17
+ end
18
+
19
+ def attach(bot)
20
+ @bot = bot
21
+ end
22
+
23
+ def consume_boot_state
24
+ path = ENV.delete(STATE_ENV)
25
+ return {} unless path && File.exist?(path)
26
+
27
+ data = JSON.parse(File.read(path))
28
+ File.delete(path)
29
+ logger&.info('Loaded restart state', shards: (data['shards'] || []).length)
30
+ data
31
+ rescue StandardError => e
32
+ logger&.error('Failed to load restart state', error: e)
33
+ {}
34
+ end
35
+
36
+ def restart!(command: nil, env: {})
37
+ raise 'Restart manager is not attached to a bot instance' unless @bot
38
+
39
+ state_path = write_restart_state
40
+ restart_command = command || default_command
41
+
42
+ logger&.info('Performing instant restart', command: restart_command)
43
+
44
+ exec(
45
+ {
46
+ STATE_ENV => state_path,
47
+ 'DISCORD_RDA_RESTARTED_AT' => Time.now.utc.iso8601
48
+ }.merge(env),
49
+ RbConfig.ruby,
50
+ *restart_command
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def write_restart_state
57
+ path = File.join(Dir.tmpdir, "discord_rda_restart_#{Process.pid}_#{SecureRandom.hex(6)}.json")
58
+ File.write(path, JSON.pretty_generate(snapshot_state))
59
+ path
60
+ end
61
+
62
+ def snapshot_state
63
+ {
64
+ pid: Process.pid,
65
+ written_at: Time.now.utc.iso8601,
66
+ total_guilds: @bot.shard_manager.total_guilds,
67
+ shards: @bot.shard_manager.shards.map do |shard|
68
+ {
69
+ shard_id: shard.instance_variable_get(:@shard_id),
70
+ shard_count: shard.instance_variable_get(:@shard_count),
71
+ session_id: shard.session_id,
72
+ sequence: shard.sequence,
73
+ resume_gateway_url: shard.resume_gateway_url
74
+ }
75
+ end
76
+ }
77
+ end
78
+
79
+ def default_command
80
+ [$PROGRAM_NAME, *ARGV]
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module DiscordRDA
7
+ class Secrets
8
+ def self.fetch(key, default: nil, required: false)
9
+ value = ENV[key.to_s]
10
+ if required && (value.nil? || value.empty?)
11
+ raise KeyError, "Missing required secret: #{key}"
12
+ end
13
+
14
+ value.nil? || value.empty? ? default : value
15
+ end
16
+
17
+ def self.load_file(path)
18
+ content = File.read(path)
19
+
20
+ case File.extname(path).downcase
21
+ when '.json'
22
+ JSON.parse(content)
23
+ when '.yml', '.yaml'
24
+ YAML.safe_load(content, aliases: true) || {}
25
+ else
26
+ raise ArgumentError, "Unsupported secrets file format: #{path}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -55,7 +55,7 @@ module DiscordRDA
55
55
  # Get the timestamp from the snowflake
56
56
  # @return [Time] The timestamp (UTC)
57
57
  def timestamp
58
- @timestamp ||= Time.at(((@value >> 22) + DISCORD_EPOCH) / 1000.0).utc
58
+ Time.at(((@value >> 22) + DISCORD_EPOCH) / 1000.0).utc
59
59
  end
60
60
  alias time timestamp
61
61
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ class Tracer
5
+ Span = Struct.new(:name, :attributes, :started_at, :finished_at, :error, keyword_init: true)
6
+
7
+ attr_reader :enabled
8
+
9
+ def initialize(enabled: false, logger: nil)
10
+ @enabled = enabled
11
+ @logger = logger
12
+ end
13
+
14
+ def with_span(name, **attributes)
15
+ return yield unless enabled
16
+
17
+ span = Span.new(name: name, attributes: attributes, started_at: Time.now.utc)
18
+ if defined?(::OpenTelemetry::Trace)
19
+ tracer = ::OpenTelemetry.tracer_provider.tracer('discord_rda')
20
+ tracer.in_span(name, attributes: attributes) { yield }
21
+ else
22
+ yield
23
+ end
24
+ rescue StandardError => e
25
+ span.error = e
26
+ raise
27
+ ensure
28
+ if enabled
29
+
30
+ span.finished_at = Time.now.utc
31
+ @logger&.debug(
32
+ 'Trace span',
33
+ span: span.name,
34
+ duration_ms: ((span.finished_at - span.started_at) * 1000).round(2),
35
+ error: span.error&.class&.name,
36
+ **span.attributes
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -51,9 +51,16 @@ module DiscordRDA
51
51
  # Initialize entity with data
52
52
  # @param data [Hash] Entity data
53
53
  def initialize(data = {})
54
- @id = data['id'] ? Snowflake.new(data['id']) : nil
55
- @raw_data = data.freeze
56
- freeze
54
+ normalized_data = data.each_with_object({}) do |(key, value), hash|
55
+ hash[key.to_s] = value
56
+ end
57
+
58
+ normalized_data.each do |key, value|
59
+ instance_variable_set("@#{key}", value)
60
+ end
61
+
62
+ @id = normalized_data['id'] ? Snowflake.new(normalized_data['id']) : nil
63
+ @raw_data = normalized_data.freeze
57
64
  end
58
65
 
59
66
  # Get raw API data
@@ -367,3 +367,5 @@ module DiscordRDA
367
367
  @components << menu
368
368
  self
369
369
  end
370
+ end
371
+ end