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.
- checksums.yaml +4 -4
- data/README.md +63 -0
- data/lib/discord_rda/bot.rb +1223 -61
- data/lib/discord_rda/cache/store.rb +2 -0
- data/lib/discord_rda/connection/gateway_client.rb +12 -2
- data/lib/discord_rda/connection/rest_client.rb +10 -1
- data/lib/discord_rda/connection/rest_proxy.rb +5 -1
- data/lib/discord_rda/connection/shard_manager.rb +10 -1
- data/lib/discord_rda/core/configuration.rb +65 -2
- data/lib/discord_rda/core/error_tracker.rb +22 -0
- data/lib/discord_rda/core/execution_supervisor.rb +180 -0
- data/lib/discord_rda/core/logger.rb +17 -3
- data/lib/discord_rda/core/restart_manager.rb +83 -0
- data/lib/discord_rda/core/secrets.rb +30 -0
- data/lib/discord_rda/core/snowflake.rb +1 -1
- data/lib/discord_rda/core/tracer.rb +41 -0
- data/lib/discord_rda/entity/base.rb +10 -3
- data/lib/discord_rda/entity/message_builder.rb +2 -0
- data/lib/discord_rda/entity/support.rb +287 -0
- data/lib/discord_rda/event/base.rb +258 -7
- data/lib/discord_rda/interactions/application_command.rb +44 -6
- data/lib/discord_rda/interactions/interaction.rb +12 -0
- data/lib/discord_rda/persistence/active_record.rb +72 -0
- data/lib/discord_rda/plugin/analytics_plugin.rb +64 -2
- data/lib/discord_rda/version.rb +1 -1
- data/lib/discord_rda.rb +6 -0
- metadata +64 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'async/websocket'
|
|
4
4
|
require 'async/http/internet'
|
|
5
5
|
require 'async/http/endpoint'
|
|
6
|
-
require '
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|