discord_store 0.1.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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ module Transport
5
+ # A token bucket over the whole bot token's request budget.
6
+ #
7
+ # This is the piece that replaces ActiveRecord's connection pool.
8
+ #
9
+ # A conventional adapter checks out a socket, because sockets are the scarce
10
+ # resource and the database will happily answer as fast as you can ask. Over
11
+ # Discord's REST API the opposite holds: connections are effectively free
12
+ # and *permission to ask* is what runs out, at roughly fifty requests per
13
+ # second per token, globally, across every channel and every process sharing
14
+ # that token.
15
+ #
16
+ # So the checkout primitive here is a semaphore over quota rather than over
17
+ # connections, and +pool:+ in database.yml means nothing. The failure mode is
18
+ # the same one Rails users know — a timeout under concurrency — which is why
19
+ # {QuotaTimeoutError} is deliberately shaped like ConnectionTimeoutError. It
20
+ # arrives for a different reason, and raising the pool size cannot fix it.
21
+ #
22
+ # Fiber-safe: Mutex and ConditionVariable both defer to the fiber scheduler
23
+ # when one is installed, so a Falcon or async worker parks its fiber here
24
+ # instead of blocking its thread.
25
+ class Quota
26
+ # @return [Float] permits per second
27
+ attr_reader :rate
28
+
29
+ # @param rate [Numeric] permits per second
30
+ # @param burst [Numeric, nil] bucket capacity; defaults to one second of rate
31
+ # @param clock [#call] returns a monotonic float, injectable for tests. It
32
+ # must advance: both the refill and the acquire deadline are measured
33
+ # against it, so a frozen clock waits forever by construction.
34
+ def initialize(rate:, burst: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
35
+ raise ArgumentError, "rate must be positive" unless rate.to_f.positive?
36
+
37
+ @rate = rate.to_f
38
+ @capacity = (burst || rate).to_f
39
+ @tokens = @capacity
40
+ @clock = clock
41
+ @last_refill = @clock.call
42
+ @mutex = Mutex.new
43
+ @condition = ConditionVariable.new
44
+ end
45
+
46
+ # Blocks until one permit is available, then consumes it.
47
+ #
48
+ # @param timeout [Numeric, nil] seconds to wait; nil waits forever
49
+ # @return [void]
50
+ # @raise [QuotaTimeoutError] if no permit became available in time
51
+ def acquire(timeout: nil)
52
+ deadline = timeout && (@clock.call + timeout)
53
+
54
+ @mutex.synchronize do
55
+ loop do
56
+ refill
57
+
58
+ if @tokens >= 1.0
59
+ @tokens -= 1.0
60
+ return
61
+ end
62
+
63
+ wait = (1.0 - @tokens) / @rate
64
+
65
+ if deadline
66
+ remaining = deadline - @clock.call
67
+ raise QuotaTimeoutError, timeout_message(timeout) if remaining <= 0
68
+
69
+ wait = [wait, remaining].min
70
+ end
71
+
72
+ @condition.wait(@mutex, wait)
73
+ end
74
+ end
75
+ end
76
+
77
+ # Runs the block having first acquired a permit.
78
+ #
79
+ # @return [Object] the block's value
80
+ def with(timeout: nil)
81
+ acquire(timeout: timeout)
82
+ yield
83
+ end
84
+
85
+ # Hands a permit back, for a request that never actually left. Capped at
86
+ # capacity so that returning more than we took cannot inflate the budget.
87
+ #
88
+ # @return [void]
89
+ def release
90
+ @mutex.synchronize do
91
+ @tokens = [@tokens + 1.0, @capacity].min
92
+ @condition.signal
93
+ end
94
+ end
95
+
96
+ # Drains the bucket and refuses permits for +seconds+. Called when Discord
97
+ # answers 429 with a global scope: the server has told us our estimate of
98
+ # the budget was wrong, and its number wins.
99
+ #
100
+ # @param seconds [Numeric]
101
+ # @return [void]
102
+ def penalize(seconds)
103
+ @mutex.synchronize do
104
+ @tokens = 0.0
105
+ # Rewinding the refill clock into the future makes the next refill a
106
+ # no-op until the penalty has elapsed, without a separate timer.
107
+ @last_refill = @clock.call + seconds.to_f
108
+ end
109
+ end
110
+
111
+ # @return [Float] permits currently available, for tests and instrumentation
112
+ def available
113
+ @mutex.synchronize do
114
+ refill
115
+ @tokens
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def refill
122
+ now = @clock.call
123
+ elapsed = now - @last_refill
124
+ return if elapsed <= 0 # penalized, or the clock did not move
125
+
126
+ @tokens = [@tokens + (elapsed * @rate), @capacity].min
127
+ @last_refill = now
128
+ end
129
+
130
+ def timeout_message(timeout)
131
+ "waited #{timeout}s for Discord request quota (#{@rate}/s) and never got it. " \
132
+ "This is not a connection pool problem and a larger pool will not fix it: " \
133
+ "the token's global request budget is exhausted. Shed load, batch writes, " \
134
+ "or shard across more channels."
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quota"
4
+ require_relative "bucket"
5
+
6
+ module DiscordStore
7
+ module Transport
8
+ # Learns Discord's rate-limit topology at runtime and holds requests back to
9
+ # fit inside it.
10
+ #
11
+ # Two layers, because Discord enforces two:
12
+ #
13
+ # Quota — the token-wide budget, roughly 50 requests/second across
14
+ # everything. One instance per process.
15
+ # Bucket — a per-route, per-major-parameter window (five messages per five
16
+ # seconds in a given channel, and so on). Learned from response
17
+ # headers; several route keys may resolve to one shared bucket.
18
+ #
19
+ # A request must satisfy both before it goes out, and both are updated from
20
+ # every response, including the failures.
21
+ class RateLimiter
22
+ # Response headers Discord uses to describe the limit that applied.
23
+ HEADER_BUCKET = "x-ratelimit-bucket"
24
+ HEADER_LIMIT = "x-ratelimit-limit"
25
+ HEADER_REMAINING = "x-ratelimit-remaining"
26
+ HEADER_RESET_AFTER = "x-ratelimit-reset-after"
27
+ HEADER_SCOPE = "x-ratelimit-scope"
28
+ HEADER_GLOBAL = "x-ratelimit-global"
29
+ HEADER_RETRY_AFTER = "retry-after"
30
+
31
+ attr_reader :quota
32
+
33
+ # @param rate [Numeric] global permits per second
34
+ # @param clock [#call]
35
+ def initialize(rate:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
36
+ @quota = Quota.new(rate: rate, clock: clock)
37
+ @clock = clock
38
+ @route_buckets = {}
39
+ @shared_buckets = {}
40
+ @registry_mutex = Mutex.new
41
+ end
42
+
43
+ # Acquires both layers of permission for +route_key+.
44
+ #
45
+ # @param route_key [String] method plus path with major parameters resolved,
46
+ # e.g. "POST /channels/123/messages"
47
+ # @param timeout [Numeric, nil]
48
+ # @return [void]
49
+ def acquire(route_key, timeout: nil)
50
+ started = @clock.call
51
+ @quota.acquire(timeout: timeout)
52
+
53
+ remaining = timeout && [timeout - (@clock.call - started), 0.0].max
54
+ bucket_for(route_key).acquire(timeout: remaining)
55
+ end
56
+
57
+ # Folds a response's rate-limit headers back into local state.
58
+ #
59
+ # @param route_key [String]
60
+ # @param headers [Hash] downcased header name => value
61
+ # @return [void]
62
+ def observe(route_key, headers)
63
+ headers = normalize(headers)
64
+ bucket = bucket_for(route_key)
65
+
66
+ if (hash_key = headers[HEADER_BUCKET])
67
+ bucket = adopt_shared_bucket(route_key, bucket, hash_key)
68
+ end
69
+
70
+ bucket.update(
71
+ limit: headers[HEADER_LIMIT],
72
+ remaining: headers[HEADER_REMAINING],
73
+ reset_after: headers[HEADER_RESET_AFTER]
74
+ )
75
+ end
76
+
77
+ # Applies the server-mandated backoff from a 429.
78
+ #
79
+ # @param route_key [String]
80
+ # @param headers [Hash]
81
+ # @return [Float] seconds the caller should wait before retrying
82
+ def penalize(route_key, headers)
83
+ headers = normalize(headers)
84
+ retry_after = (headers[HEADER_RESET_AFTER] || headers[HEADER_RETRY_AFTER]).to_f
85
+ retry_after = 1.0 if retry_after <= 0
86
+
87
+ if global?(headers)
88
+ # The token is over budget everywhere; holding back one bucket would
89
+ # accomplish nothing.
90
+ @quota.penalize(retry_after)
91
+ else
92
+ bucket_for(route_key).penalize(retry_after)
93
+ end
94
+
95
+ retry_after
96
+ end
97
+
98
+ # @param headers [Hash]
99
+ # @return [Boolean] whether a 429 applied to the whole token
100
+ def global?(headers)
101
+ headers = normalize(headers)
102
+ headers[HEADER_GLOBAL].to_s == "true" || headers[HEADER_SCOPE].to_s == "global"
103
+ end
104
+
105
+ # @return [Hash] every known bucket, for instrumentation and tests
106
+ def inspect_buckets
107
+ @registry_mutex.synchronize { @route_buckets.transform_values(&:state) }
108
+ end
109
+
110
+ private
111
+
112
+ def bucket_for(route_key)
113
+ @registry_mutex.synchronize do
114
+ @route_buckets[route_key] ||= Bucket.new(clock: @clock)
115
+ end
116
+ end
117
+
118
+ # Discord groups routes into shared buckets. Once we learn that this route
119
+ # belongs to a bucket we have already seen, we point the route at the
120
+ # existing bucket object so both routes draw down one window instead of
121
+ # each keeping a private, over-optimistic count.
122
+ def adopt_shared_bucket(route_key, bucket, hash_key)
123
+ @registry_mutex.synchronize do
124
+ existing = @shared_buckets[hash_key]
125
+
126
+ if existing.nil?
127
+ bucket.hash_key = hash_key
128
+ @shared_buckets[hash_key] = bucket
129
+ bucket
130
+ elsif existing.equal?(bucket)
131
+ bucket
132
+ else
133
+ @route_buckets[route_key] = existing
134
+ existing
135
+ end
136
+ end
137
+ end
138
+
139
+ def normalize(headers)
140
+ return {} if headers.nil?
141
+
142
+ headers.each_with_object({}) do |(key, value), out|
143
+ out[key.to_s.downcase] = value.is_a?(Array) ? value.first : value
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "http"
5
+ require_relative "rate_limiter"
6
+
7
+ module DiscordStore
8
+ module Transport
9
+ # The Discord REST surface this library uses, and nothing else.
10
+ #
11
+ # Deliberately small. Every method here exists to read or write data that
12
+ # this bot itself wrote. There is no message search, no member enumeration,
13
+ # no history export, and there will not be: the difference between a storage
14
+ # backend and a scraper is whether it can read other people's messages, and
15
+ # that difference is enforced in {#assert_own_message!} rather than in a
16
+ # paragraph of the README.
17
+ class REST
18
+ MESSAGE_PAGE_LIMIT = 100
19
+ BULK_DELETE_LIMIT = 100
20
+
21
+ # Discord refuses to bulk-delete messages older than two weeks. Past that
22
+ # the only route is one request per message, which is why the default
23
+ # delete policy is to tombstone instead.
24
+ BULK_DELETE_MAX_AGE = 14 * 24 * 60 * 60
25
+
26
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504, 599].freeze
27
+
28
+ attr_reader :config, :rate_limiter
29
+
30
+ # @param config [DiscordStore::Configuration]
31
+ # @param http [#call] an HTTP backend; defaults to Net::HTTP
32
+ # @param rate_limiter [RateLimiter, nil]
33
+ def initialize(config:, http: nil, rate_limiter: nil)
34
+ @config = config.validate!
35
+ @http = http || NetHTTP.new(
36
+ open_timeout: config.open_timeout,
37
+ read_timeout: config.read_timeout,
38
+ write_timeout: config.write_timeout
39
+ )
40
+ @rate_limiter = rate_limiter || RateLimiter.new(rate: config.global_rate_limit)
41
+ end
42
+
43
+ # --- Messages ----------------------------------------------------------
44
+
45
+ # @param channel_id [String]
46
+ # @param content [String, nil]
47
+ # @param nonce [String, nil] Discord deduplicates on this within a short
48
+ # window, which turns an at-least-once retry into an at-most-once write
49
+ # @param files [Array<Hash>] each {filename:, content:, content_type:}
50
+ # @return [Hash] the created message
51
+ def create_message(channel_id, content: nil, nonce: nil, files: [])
52
+ payload = {}
53
+ payload[:content] = content if content
54
+ payload[:nonce] = nonce if nonce
55
+ # Belt and braces: we are writing machine data, and a stray @everyone
56
+ # inside a base64 envelope should never page a guild.
57
+ payload[:allowed_mentions] = { parse: [] }
58
+
59
+ if files.empty?
60
+ request(:post, "/channels/#{channel_id}/messages",
61
+ json: payload,
62
+ route: "POST /channels/#{channel_id}/messages")
63
+ else
64
+ content_type, body = Multipart.encode(JSON.generate(payload), files)
65
+ request(:post, "/channels/#{channel_id}/messages",
66
+ body: body,
67
+ content_type: content_type,
68
+ route: "POST /channels/#{channel_id}/messages")
69
+ end
70
+ end
71
+
72
+ # In-place update. This is the primitive that makes UPDATE cheap: a bot may
73
+ # edit its own messages indefinitely, so a mutable row does not have to be
74
+ # rewritten as delete-then-insert.
75
+ #
76
+ # @return [Hash] the edited message
77
+ def edit_message(channel_id, message_id, content:)
78
+ request(:patch, "/channels/#{channel_id}/messages/#{message_id}",
79
+ json: { content: content, allowed_mentions: { parse: [] } },
80
+ route: "PATCH /channels/#{channel_id}/messages/:id")
81
+ end
82
+
83
+ # @return [Hash]
84
+ def get_message(channel_id, message_id)
85
+ message = request(:get, "/channels/#{channel_id}/messages/#{message_id}",
86
+ route: "GET /channels/#{channel_id}/messages/:id")
87
+ assert_own_message!(message)
88
+ message
89
+ end
90
+
91
+ # Pages backwards or forwards through a channel.
92
+ #
93
+ # +before+ and +after+ take snowflakes, so {Snowflake.from_time} turns a
94
+ # wall-clock range into a server-side range scan.
95
+ #
96
+ # @param channel_id [String]
97
+ # @param before [String, Integer, nil]
98
+ # @param after [String, Integer, nil]
99
+ # @param limit [Integer]
100
+ # @return [Array<Hash>] our own messages only, newest first
101
+ def list_messages(channel_id, before: nil, after: nil, limit: MESSAGE_PAGE_LIMIT)
102
+ query = { limit: [limit, MESSAGE_PAGE_LIMIT].min }
103
+ query[:before] = before.to_s if before
104
+ query[:after] = after.to_s if after
105
+
106
+ messages = request(:get, "/channels/#{channel_id}/messages",
107
+ query: query,
108
+ route: "GET /channels/#{channel_id}/messages")
109
+
110
+ # A channel is a chat room before it is a table. Anything a human said in
111
+ # it is not ours, is not our business, and is silently skipped.
112
+ Array(messages).select { |message| own_message?(message) }
113
+ end
114
+
115
+ # Walks a channel in ascending snowflake order, yielding each of our
116
+ # messages. This is the replay path.
117
+ #
118
+ # @param channel_id [String]
119
+ # @param after [String, Integer, nil] exclusive lower bound
120
+ # @param until_id [String, Integer, nil] inclusive upper bound
121
+ # @yieldparam message [Hash]
122
+ # @return [void]
123
+ def each_message(channel_id, after: nil, until_id: nil)
124
+ return enum_for(:each_message, channel_id, after: after, until_id: until_id) unless block_given?
125
+
126
+ cursor = after ? after.to_s : "0"
127
+
128
+ loop do
129
+ page = request(:get, "/channels/#{channel_id}/messages",
130
+ query: { limit: MESSAGE_PAGE_LIMIT, after: cursor },
131
+ route: "GET /channels/#{channel_id}/messages")
132
+ break if page.nil? || page.empty?
133
+
134
+ # With +after+, Discord returns newest-first within the page; ascending
135
+ # order is what a log replay needs.
136
+ page = page.sort_by { |message| message["id"].to_i }
137
+
138
+ page.each do |message|
139
+ # Deliberately a non-local exit: passing the upper bound means the
140
+ # caller wants the walk to stop, not just this page.
141
+ return if until_id && message["id"].to_i > until_id.to_i # rubocop:disable Lint/NonLocalExitFromIterator
142
+
143
+ yield message if own_message?(message)
144
+ end
145
+
146
+ cursor = page.last["id"]
147
+ break if page.size < MESSAGE_PAGE_LIMIT
148
+ end
149
+ end
150
+
151
+ # @return [void]
152
+ def delete_message(channel_id, message_id)
153
+ request(:delete, "/channels/#{channel_id}/messages/#{message_id}",
154
+ route: "DELETE /channels/#{channel_id}/messages/:id")
155
+ nil
156
+ end
157
+
158
+ # Deletes up to 100 messages in one request. Only works for messages under
159
+ # two weeks old; older ones must go one at a time.
160
+ #
161
+ # @param channel_id [String]
162
+ # @param message_ids [Array<String>]
163
+ # @return [Array<String>] ids that were too old for bulk deletion
164
+ def bulk_delete_messages(channel_id, message_ids)
165
+ ids = Array(message_ids).map(&:to_s)
166
+ return [] if ids.empty?
167
+
168
+ cutoff = Time.now - BULK_DELETE_MAX_AGE
169
+ fresh, stale = ids.partition { |id| Snowflake.at(id) > cutoff }
170
+
171
+ fresh.each_slice(BULK_DELETE_LIMIT) do |slice|
172
+ if slice.size == 1
173
+ delete_message(channel_id, slice.first)
174
+ else
175
+ request(:post, "/channels/#{channel_id}/messages/bulk-delete",
176
+ json: { messages: slice },
177
+ route: "POST /channels/#{channel_id}/messages/bulk-delete")
178
+ end
179
+ end
180
+
181
+ stale
182
+ end
183
+
184
+ # --- Guild and channel metadata ----------------------------------------
185
+
186
+ # @return [Hash]
187
+ def get_guild(guild_id)
188
+ request(:get, "/guilds/#{guild_id}", route: "GET /guilds/#{guild_id}")
189
+ end
190
+
191
+ # @return [Hash]
192
+ def get_channel(channel_id)
193
+ request(:get, "/channels/#{channel_id}", route: "GET /channels/#{channel_id}")
194
+ end
195
+
196
+ # @return [Hash] the bot's own user object
197
+ def current_user
198
+ request(:get, "/users/@me", route: "GET /users/@me")
199
+ end
200
+
201
+ # --- CDN ---------------------------------------------------------------
202
+
203
+ # Fetches an attachment from the CDN.
204
+ #
205
+ # The URL must have been resolved from a live message immediately before
206
+ # this call. Discord CDN links carry an HMAC signature over an expiry
207
+ # timestamp and stop working roughly a day after they are issued, so a
208
+ # stored URL is a bug, not a cache.
209
+ #
210
+ # @param url [String]
211
+ # @param range [Range, nil] byte range, if the caller wants a slice
212
+ # @return [String] binary body
213
+ def download(url, range: nil)
214
+ headers = { "User-Agent" => user_agent }
215
+ headers["Range"] = "bytes=#{range.begin}-#{range.end}" if range
216
+
217
+ response = @http.call(Request.new(verb: :get, url: url, headers: headers))
218
+
219
+ unless response.success?
220
+ raise APIError.new("CDN fetch failed", status: response.status, response_body: response.body)
221
+ end
222
+
223
+ response.body
224
+ end
225
+
226
+ # --- Guards ------------------------------------------------------------
227
+
228
+ # @param message [Hash]
229
+ # @return [Boolean]
230
+ def own_message?(message)
231
+ return true unless config.own_messages_only
232
+ return false unless message.is_a?(Hash)
233
+
234
+ message.dig("author", "id").to_s == config.application_id.to_s
235
+ end
236
+
237
+ # @raise [ForeignMessageError] if the message was not written by this bot
238
+ # @return [void]
239
+ def assert_own_message!(message)
240
+ return if own_message?(message)
241
+
242
+ raise ForeignMessageError,
243
+ "message #{message["id"]} was written by #{message.dig("author", "id").inspect}, " \
244
+ "not by this application (#{config.application_id.inspect}). discord_store only " \
245
+ "reads messages it wrote."
246
+ end
247
+
248
+ # @return [void]
249
+ def close
250
+ @http.close if @http.respond_to?(:close)
251
+ end
252
+
253
+ private
254
+
255
+ def request(method, path, json: nil, body: nil, query: nil, content_type: nil, route: nil)
256
+ route ||= "#{method.to_s.upcase} #{path}"
257
+ url = build_url(path, query)
258
+
259
+ headers = {
260
+ "Authorization" => "Bot #{config.token}",
261
+ "User-Agent" => user_agent,
262
+ "Accept" => "application/json"
263
+ }
264
+
265
+ if json
266
+ body = JSON.generate(json)
267
+ headers["Content-Type"] = "application/json"
268
+ elsif content_type
269
+ headers["Content-Type"] = content_type
270
+ end
271
+
272
+ perform(Request.new(verb: method, url: url, headers: headers, body: body), route)
273
+ end
274
+
275
+ def perform(request, route)
276
+ attempt = 0
277
+
278
+ loop do
279
+ attempt += 1
280
+ @rate_limiter.acquire(route, timeout: config.quota_timeout)
281
+
282
+ response = @http.call(request)
283
+ @rate_limiter.observe(route, response.headers)
284
+
285
+ return decode(response) if response.success?
286
+
287
+ if response.rate_limited?
288
+ wait = @rate_limiter.penalize(route, response.headers)
289
+ raise_exhausted(route, attempt, response) if attempt > config.max_retries
290
+
291
+ log(:warn) { "429 on #{route}; sleeping #{wait}s (attempt #{attempt})" }
292
+ sleep(wait)
293
+ next
294
+ end
295
+
296
+ if RETRYABLE_STATUSES.include?(response.status)
297
+ raise_exhausted(route, attempt, response) if attempt > config.max_retries
298
+
299
+ backoff = exponential_backoff(attempt)
300
+ log(:warn) { "#{response.status} on #{route}; retrying in #{backoff}s (attempt #{attempt})" }
301
+ sleep(backoff)
302
+ next
303
+ end
304
+
305
+ raise_for_status(response, route)
306
+ end
307
+ end
308
+
309
+ def decode(response)
310
+ return nil if response.status == 204
311
+
312
+ response.json
313
+ end
314
+
315
+ def raise_for_status(response, route)
316
+ payload = response.json
317
+ code = payload.is_a?(Hash) ? payload["code"] : nil
318
+ message = payload.is_a?(Hash) ? payload["message"] : response.body
319
+ detail = "#{route} failed with #{response.status}: #{message}"
320
+
321
+ case response.status
322
+ when 401, 403
323
+ raise AuthError.new(detail, status: response.status, code: code, response_body: response.body)
324
+ when 404
325
+ raise NotFoundError.new(detail, status: response.status, code: code, response_body: response.body)
326
+ else
327
+ raise APIError.new(detail, status: response.status, code: code, response_body: response.body)
328
+ end
329
+ end
330
+
331
+ def raise_exhausted(route, attempt, response)
332
+ raise ExhaustedError,
333
+ "#{route} still failing with #{response.status} after #{attempt - 1} retries"
334
+ end
335
+
336
+ # Full jitter: retrying a rate-limited endpoint on a fixed schedule from
337
+ # several workers reproduces the thundering herd that caused the limit.
338
+ def exponential_backoff(attempt)
339
+ ceiling = [2.0**(attempt - 1), 30.0].min
340
+ rand * ceiling
341
+ end
342
+
343
+ def build_url(path, query)
344
+ url = "#{config.api_base}#{path}"
345
+ return url if query.nil? || query.empty?
346
+
347
+ require "uri"
348
+ "#{url}?#{URI.encode_www_form(query)}"
349
+ end
350
+
351
+ def user_agent
352
+ config.user_agent ||
353
+ "DiscordBot (https://github.com/chayuto/discord_store, #{DiscordStore::VERSION})"
354
+ end
355
+
356
+ def log(level, &)
357
+ config.logger&.public_send(level, &)
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ VERSION = "0.1.0"
5
+ end