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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ module Transport
5
+ # One Discord rate-limit bucket.
6
+ #
7
+ # Discord does not publish its per-route limits; it reports them, per
8
+ # response, in the X-RateLimit-* headers, and groups routes into opaque
9
+ # buckets identified by X-RateLimit-Bucket. Several routes can share one
10
+ # bucket, so the limiter learns the mapping at runtime rather than assuming
11
+ # a table of constants that will be wrong by next quarter.
12
+ #
13
+ # The limit that dominates this library is five messages per five seconds
14
+ # per channel. That is the number that caps a naive Discord-backed store,
15
+ # and the only real answer to it is to write to more than one channel —
16
+ # which is why {ChannelShard} exists.
17
+ class Bucket
18
+ UNKNOWN = nil
19
+
20
+ attr_reader :hash_key
21
+
22
+ def initialize(hash_key: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
23
+ @hash_key = hash_key
24
+ @clock = clock
25
+ @limit = UNKNOWN
26
+ @remaining = UNKNOWN
27
+ @reset_at = nil
28
+ @mutex = Mutex.new
29
+ @condition = ConditionVariable.new
30
+ end
31
+
32
+ # Blocks until this bucket has room, then claims a slot optimistically.
33
+ # The claim is provisional: {#update} reconciles it against whatever the
34
+ # server actually reports, and the server's number always wins.
35
+ #
36
+ # @param timeout [Numeric, nil]
37
+ # @return [void]
38
+ # @raise [QuotaTimeoutError]
39
+ def acquire(timeout: nil)
40
+ deadline = timeout && (@clock.call + timeout)
41
+
42
+ @mutex.synchronize do
43
+ loop do
44
+ expire_window
45
+
46
+ # Nothing learned yet: let one request through so there is something
47
+ # to learn from.
48
+ if @remaining == UNKNOWN
49
+ @remaining = UNKNOWN
50
+ return
51
+ end
52
+
53
+ if @remaining.positive?
54
+ @remaining -= 1
55
+ return
56
+ end
57
+
58
+ wait = [@reset_at.to_f - @clock.call, 0.0].max
59
+
60
+ if deadline
61
+ remaining_time = deadline - @clock.call
62
+ if remaining_time <= 0
63
+ raise QuotaTimeoutError,
64
+ "waited #{timeout}s for rate-limit bucket #{@hash_key || "(unlearned)"} to reset"
65
+ end
66
+
67
+ wait = [wait, remaining_time].min
68
+ end
69
+
70
+ # A zero wait with no deadline would spin; give the scheduler a tick.
71
+ @condition.wait(@mutex, wait.positive? ? wait : 0.01)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Reconciles local state with the headers on a response.
77
+ #
78
+ # @param limit [Integer, nil]
79
+ # @param remaining [Integer, nil]
80
+ # @param reset_after [Float, nil] seconds until the window resets
81
+ # @return [void]
82
+ def update(limit: nil, remaining: nil, reset_after: nil)
83
+ @mutex.synchronize do
84
+ @limit = Integer(limit) if limit
85
+ @reset_at = @clock.call + Float(reset_after) if reset_after
86
+
87
+ if remaining
88
+ reported = Integer(remaining)
89
+ # Requests we have already let through but whose responses have not
90
+ # come back yet are not reflected in the server's count, so take the
91
+ # pessimistic view.
92
+ @remaining = @remaining == UNKNOWN ? reported : [@remaining, reported].min
93
+ end
94
+
95
+ @condition.broadcast
96
+ end
97
+ end
98
+
99
+ # Records the bucket hash the server assigned to this route.
100
+ #
101
+ # @param hash_key [String]
102
+ # @return [void]
103
+ def hash_key=(hash_key)
104
+ @mutex.synchronize { @hash_key = hash_key }
105
+ end
106
+
107
+ # Closes the bucket for +seconds+ after a 429.
108
+ #
109
+ # @param seconds [Numeric]
110
+ # @return [void]
111
+ def penalize(seconds)
112
+ @mutex.synchronize do
113
+ @remaining = 0
114
+ @reset_at = @clock.call + seconds.to_f
115
+ end
116
+ end
117
+
118
+ # @return [Hash] a snapshot, for instrumentation and tests
119
+ def state
120
+ @mutex.synchronize do
121
+ {
122
+ hash_key: @hash_key,
123
+ limit: @limit,
124
+ remaining: @remaining,
125
+ reset_in: @reset_at ? [@reset_at - @clock.call, 0.0].max : nil
126
+ }
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # Assumes the mutex is held.
133
+ def expire_window
134
+ return unless @reset_at && @clock.call >= @reset_at
135
+
136
+ @remaining = @limit == UNKNOWN ? UNKNOWN : @limit
137
+ @reset_at = nil
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+ require "securerandom"
7
+ require_relative "http"
8
+
9
+ module DiscordStore
10
+ module Transport
11
+ # An in-memory Discord, good enough to develop and test the whole stack
12
+ # against without a bot token.
13
+ #
14
+ # It models the behaviour that actually shapes this library's design, not
15
+ # just the happy path:
16
+ #
17
+ # * snowflake IDs that really are monotonic and time-encoded
18
+ # * CDN links that carry an +ex+ expiry and stop working when it passes,
19
+ # so any code that caches a URL instead of re-resolving it fails here
20
+ # rather than in production a day after deploy
21
+ # * rate-limit headers, and 429s on demand
22
+ # * a two-week bulk-delete window
23
+ # * messages from other authors, so the own-messages guard is exercised
24
+ #
25
+ # Not a general-purpose Discord mock. It implements exactly the endpoints
26
+ # {REST} calls.
27
+ class Fake
28
+ CDN_HOST = "https://cdn.discordapp.test"
29
+ CDN_TTL = 24 * 60 * 60
30
+
31
+ attr_reader :application_id, :channels, :requests
32
+
33
+ # @param application_id [String] the id the fake attributes our writes to
34
+ # @param clock [#call] returns a Time; move it forward to test expiry
35
+ # @param rate_limit [Hash] the window the fake advertises in its headers.
36
+ # Permissive by default so that tests exercise logic rather than sleep;
37
+ # pass Discord's real per-channel window ({limit: 5, reset_after: 5.0})
38
+ # when the point of the test is the limiter itself.
39
+ def initialize(application_id: "111111111111111111", clock: -> { Time.now },
40
+ rate_limit: { limit: 1000, reset_after: 0.05 })
41
+ @application_id = application_id.to_s
42
+ @clock = clock
43
+ @rate_limit = rate_limit
44
+ @channels = Hash.new { |hash, key| hash[key] = [] }
45
+ @attachments = {}
46
+ @guilds = {}
47
+ @requests = []
48
+ @pending_rate_limits = 0
49
+ @sequence = 0
50
+ @mutex = Mutex.new
51
+ end
52
+
53
+ # @param request [Request]
54
+ # @return [Response]
55
+ def call(request)
56
+ @mutex.synchronize do
57
+ @requests << request
58
+ uri = URI.parse(request.url)
59
+
60
+ return cdn_response(uri) if uri.host == URI.parse(CDN_HOST).host
61
+
62
+ if @pending_rate_limits.positive?
63
+ @pending_rate_limits -= 1
64
+ return rate_limited_response
65
+ end
66
+
67
+ dispatch(request, uri)
68
+ end
69
+ end
70
+
71
+ def close; end
72
+
73
+ # --- Test helpers ------------------------------------------------------
74
+
75
+ # Registers a guild so chunk-size discovery has something to read.
76
+ #
77
+ # @param id [String]
78
+ # @param premium_tier [Integer] 0..3
79
+ # @return [void]
80
+ def seed_guild(id, premium_tier: 0)
81
+ @guilds[id.to_s] = { "id" => id.to_s, "premium_tier" => premium_tier, "name" => "fake" }
82
+ end
83
+
84
+ # Adds a message this library did not write, to prove it gets skipped.
85
+ #
86
+ # @return [Hash] the message
87
+ def seed_foreign_message(channel_id, content:, author_id: "999999999999999999")
88
+ message = build_message(channel_id, content: content, author_id: author_id)
89
+ @channels[channel_id.to_s] << message
90
+ message
91
+ end
92
+
93
+ # Makes the next +count+ API calls answer 429.
94
+ #
95
+ # @return [void]
96
+ def inject_rate_limits(count: 1)
97
+ @pending_rate_limits = count
98
+ end
99
+
100
+ # @param channel_id [String]
101
+ # @return [Array<Hash>]
102
+ def messages_in(channel_id) = @channels[channel_id.to_s]
103
+
104
+ # @return [Integer] total API calls seen, for asserting on request counts
105
+ def request_count = @requests.size
106
+
107
+ # @return [Integer] bytes currently held across every attachment
108
+ def stored_bytes = @attachments.values.sum { |a| a[:content].bytesize }
109
+
110
+ private
111
+
112
+ def dispatch(request, uri)
113
+ method = request.verb.to_s.upcase
114
+ path = uri.path
115
+ query = URI.decode_www_form(uri.query.to_s).to_h
116
+
117
+ case method
118
+ when "POST"
119
+ case path
120
+ when %r{/channels/(\d+)/messages/bulk-delete\z} then bulk_delete(::Regexp.last_match(1), request)
121
+ when %r{/channels/(\d+)/messages\z} then create_message(::Regexp.last_match(1), request)
122
+ else not_found
123
+ end
124
+ when "PATCH"
125
+ if path =~ %r{/channels/(\d+)/messages/(\d+)\z}
126
+ edit_message(::Regexp.last_match(1), ::Regexp.last_match(2), request)
127
+ else
128
+ not_found
129
+ end
130
+ when "DELETE"
131
+ if path =~ %r{/channels/(\d+)/messages/(\d+)\z}
132
+ delete_message(::Regexp.last_match(1), ::Regexp.last_match(2))
133
+ else
134
+ not_found
135
+ end
136
+ when "GET"
137
+ dispatch_get(path, query)
138
+ else
139
+ not_found
140
+ end
141
+ end
142
+
143
+ def dispatch_get(path, query)
144
+ case path
145
+ when %r{/channels/(\d+)/messages/(\d+)\z}
146
+ get_message(::Regexp.last_match(1), ::Regexp.last_match(2))
147
+ when %r{/channels/(\d+)/messages\z}
148
+ list_messages(::Regexp.last_match(1), query)
149
+ when %r{/channels/(\d+)\z}
150
+ ok({ "id" => ::Regexp.last_match(1), "type" => 0 })
151
+ when %r{/guilds/(\d+)\z}
152
+ guild = @guilds[::Regexp.last_match(1)]
153
+ guild ? ok(guild) : not_found
154
+ when %r{/users/@me\z}
155
+ ok({ "id" => @application_id, "bot" => true })
156
+ else
157
+ not_found
158
+ end
159
+ end
160
+
161
+ # --- Endpoint implementations -----------------------------------------
162
+
163
+ def create_message(channel_id, request)
164
+ payload, files = parse_body(request)
165
+
166
+ nonce = payload["nonce"]
167
+ if nonce
168
+ existing = @channels[channel_id].find { |m| m["nonce"] == nonce }
169
+ # Discord deduplicates on nonce within a short window. Modelling it is
170
+ # what makes retry-after-timeout safe to test.
171
+ return ok(existing) if existing
172
+ end
173
+
174
+ message = build_message(channel_id, content: payload["content"].to_s, author_id: @application_id)
175
+ message["nonce"] = nonce if nonce
176
+
177
+ message["attachments"] = files.map.with_index do |file, index|
178
+ store_attachment(channel_id, message["id"], index, file)
179
+ end
180
+
181
+ @channels[channel_id] << message
182
+ ok(message)
183
+ end
184
+
185
+ def edit_message(channel_id, message_id, request)
186
+ message = find_message(channel_id, message_id)
187
+ return not_found unless message
188
+
189
+ payload, = parse_body(request)
190
+ message["content"] = payload["content"].to_s
191
+ message["edited_timestamp"] = @clock.call.utc.iso8601
192
+ ok(message)
193
+ end
194
+
195
+ def get_message(channel_id, message_id)
196
+ message = find_message(channel_id, message_id)
197
+ return not_found unless message
198
+
199
+ ok(refresh_attachment_urls(message))
200
+ end
201
+
202
+ def list_messages(channel_id, query)
203
+ limit = (query["limit"] || 50).to_i
204
+ messages = @channels[channel_id].sort_by { |m| m["id"].to_i }
205
+
206
+ if (after = query["after"])
207
+ messages = messages.select { |m| m["id"].to_i > after.to_i }
208
+ messages = messages.first(limit)
209
+ elsif (before = query["before"])
210
+ messages = messages.select { |m| m["id"].to_i < before.to_i }
211
+ messages = messages.last(limit)
212
+ else
213
+ messages = messages.last(limit)
214
+ end
215
+
216
+ # Discord returns newest first.
217
+ ok(messages.reverse.map { |m| refresh_attachment_urls(m) })
218
+ end
219
+
220
+ def delete_message(channel_id, message_id)
221
+ removed = @channels[channel_id].reject! { |m| m["id"] == message_id.to_s }
222
+ return not_found if removed.nil?
223
+
224
+ Response.new(status: 204, headers: rate_limit_headers, body: nil)
225
+ end
226
+
227
+ def bulk_delete(channel_id, request)
228
+ payload, = parse_body(request)
229
+ ids = Array(payload["messages"]).map(&:to_s)
230
+
231
+ cutoff = @clock.call - REST::BULK_DELETE_MAX_AGE
232
+ if ids.any? { |id| Snowflake.at(id) <= cutoff }
233
+ return Response.new(
234
+ status: 400,
235
+ headers: rate_limit_headers,
236
+ body: JSON.generate({
237
+ "code" => 50_034,
238
+ "message" => "You can only bulk delete messages that are " \
239
+ "under 14 days old."
240
+ })
241
+ )
242
+ end
243
+
244
+ @channels[channel_id].reject! { |m| ids.include?(m["id"]) }
245
+ Response.new(status: 204, headers: rate_limit_headers, body: nil)
246
+ end
247
+
248
+ def cdn_response(uri)
249
+ segments = uri.path.split("/").reject(&:empty?)
250
+ attachment_id = segments[2]
251
+ record = @attachments[attachment_id]
252
+ return not_found unless record
253
+
254
+ query = URI.decode_www_form(uri.query.to_s).to_h
255
+ expires_at = query["ex"].to_s.to_i(16)
256
+
257
+ # The whole point of the fake: an expired link 404s, exactly as Discord's
258
+ # has since it started signing them.
259
+ if expires_at.positive? && @clock.call.to_i > expires_at
260
+ return Response.new(status: 404, headers: {},
261
+ body: "This content is no longer available.")
262
+ end
263
+
264
+ Response.new(status: 200, headers: { "content-type" => "application/octet-stream" },
265
+ body: record[:content])
266
+ end
267
+
268
+ # --- Helpers -----------------------------------------------------------
269
+
270
+ def build_message(channel_id, content:, author_id:)
271
+ {
272
+ "id" => next_snowflake,
273
+ "channel_id" => channel_id.to_s,
274
+ "content" => content,
275
+ "author" => { "id" => author_id.to_s, "bot" => author_id.to_s == @application_id },
276
+ "timestamp" => @clock.call.utc.iso8601,
277
+ "attachments" => [],
278
+ "pinned" => false
279
+ }
280
+ end
281
+
282
+ def store_attachment(channel_id, _message_id, index, file)
283
+ id = next_snowflake
284
+ @attachments[id] = { content: file[:content].to_s.dup.force_encoding(Encoding::BINARY),
285
+ filename: file[:filename] }
286
+
287
+ {
288
+ "id" => id,
289
+ "filename" => file[:filename],
290
+ "size" => file[:content].to_s.bytesize,
291
+ "url" => signed_url(channel_id, id, file[:filename]),
292
+ "_index" => index
293
+ }
294
+ end
295
+
296
+ # Every read re-signs, exactly as Discord does. Code that stores the URL
297
+ # from a previous read instead of re-fetching the message will pass the
298
+ # first test and fail once the clock moves.
299
+ def refresh_attachment_urls(message)
300
+ copy = message.dup
301
+ copy["attachments"] = message["attachments"].map do |attachment|
302
+ url = signed_url(message["channel_id"], attachment["id"], attachment["filename"])
303
+ attachment.merge("url" => url)
304
+ end
305
+ copy
306
+ end
307
+
308
+ def signed_url(channel_id, attachment_id, filename)
309
+ issued = @clock.call.to_i
310
+ expires = issued + CDN_TTL
311
+ # A real HMAC over Discord's private key; here, any opaque value.
312
+ hmac = SecureRandom.hex(16)
313
+ "#{CDN_HOST}/attachments/#{channel_id}/#{attachment_id}/#{filename}" \
314
+ "?ex=#{expires.to_s(16)}&is=#{issued.to_s(16)}&hm=#{hmac}"
315
+ end
316
+
317
+ def find_message(channel_id, message_id)
318
+ @channels[channel_id.to_s].find { |m| m["id"] == message_id.to_s }
319
+ end
320
+
321
+ def next_snowflake
322
+ @sequence += 1
323
+ (Snowflake.from_time(@clock.call) | (@sequence & Snowflake::INCREMENT_MASK)).to_s
324
+ end
325
+
326
+ def parse_body(request)
327
+ content_type = (request.headers || {}).fetch("Content-Type", "")
328
+
329
+ if content_type.start_with?("multipart/form-data")
330
+ parse_multipart(request.body, content_type)
331
+ else
332
+ [JSON.parse(request.body.to_s), []]
333
+ end
334
+ rescue JSON::ParserError
335
+ [{}, []]
336
+ end
337
+
338
+ def parse_multipart(body, content_type)
339
+ boundary = content_type[/boundary=(.+)\z/, 1]
340
+ return [{}, []] unless boundary
341
+
342
+ payload = {}
343
+ files = []
344
+
345
+ body.to_s.split("--#{boundary}").each do |part|
346
+ headers, content = part.split("\r\n\r\n", 2)
347
+ next unless headers && content
348
+
349
+ content = content.sub(/\r\n\z/, "")
350
+
351
+ if headers.include?('name="payload_json"')
352
+ payload = JSON.parse(content)
353
+ elsif (filename = headers[/filename="([^"]*)"/, 1])
354
+ files << { filename: filename, content: content }
355
+ end
356
+ end
357
+
358
+ [payload, files]
359
+ end
360
+
361
+ def ok(payload)
362
+ Response.new(status: 200, headers: rate_limit_headers, body: JSON.generate(payload))
363
+ end
364
+
365
+ def not_found
366
+ Response.new(status: 404, headers: rate_limit_headers,
367
+ body: JSON.generate({ "code" => 10_008, "message" => "Unknown Message" }))
368
+ end
369
+
370
+ def rate_limited_response
371
+ Response.new(
372
+ status: 429,
373
+ headers: rate_limit_headers.merge(
374
+ "x-ratelimit-remaining" => "0",
375
+ "x-ratelimit-reset-after" => "0.01",
376
+ "retry-after" => "0.01",
377
+ "x-ratelimit-scope" => "user"
378
+ ),
379
+ body: JSON.generate({ "message" => "You are being rate limited.", "retry_after" => 0.01 })
380
+ )
381
+ end
382
+
383
+ def rate_limit_headers
384
+ limit = @rate_limit[:limit]
385
+ {
386
+ "x-ratelimit-bucket" => "fake-bucket",
387
+ "x-ratelimit-limit" => limit.to_s,
388
+ "x-ratelimit-remaining" => (limit - 1).to_s,
389
+ "x-ratelimit-reset-after" => @rate_limit[:reset_after].to_s
390
+ }
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "securerandom"
6
+
7
+ module DiscordStore
8
+ module Transport
9
+ # What every HTTP backend returns.
10
+ Response = Struct.new(:status, :headers, :body, keyword_init: true) do
11
+ def success? = status.between?(200, 299)
12
+ def rate_limited? = status == 429
13
+ def server_error? = status >= 500
14
+
15
+ def json
16
+ return nil if body.nil? || body.empty?
17
+
18
+ require "json"
19
+ JSON.parse(body)
20
+ rescue JSON::ParserError
21
+ nil
22
+ end
23
+ end
24
+
25
+ # A request that has not been sent yet. Kept as a value object so the fake
26
+ # backend can assert against exactly what the real one would have sent.
27
+ #
28
+ # The HTTP verb is +verb+, not +method+: a Struct member called method would
29
+ # shadow Object#method, and losing that on a value object passed through
30
+ # several layers is not worth the nicer name.
31
+ Request = Struct.new(:verb, :url, :headers, :body, keyword_init: true)
32
+
33
+ # Net::HTTP backend.
34
+ #
35
+ # Connections are cached per (host, port) in fiber-local storage rather than
36
+ # thread-local. Under a fiber scheduler two fibers on one thread run
37
+ # concurrently, and sharing one Net::HTTP object between them interleaves
38
+ # bytes on the socket and corrupts both responses — the same failure that
39
+ # forced Rails to grow a fiber-aware connection pool, arriving here through
40
+ # a completely different door.
41
+ class NetHTTP
42
+ # @param open_timeout [Numeric]
43
+ # @param read_timeout [Numeric]
44
+ # @param write_timeout [Numeric]
45
+ def initialize(open_timeout: 5.0, read_timeout: 30.0, write_timeout: 30.0)
46
+ @open_timeout = open_timeout
47
+ @read_timeout = read_timeout
48
+ @write_timeout = write_timeout
49
+ end
50
+
51
+ # @param request [Request]
52
+ # @return [Response]
53
+ def call(request)
54
+ uri = URI.parse(request.url)
55
+ http = connection_for(uri)
56
+
57
+ net_request = build_request(request, uri)
58
+ response = http.request(net_request)
59
+
60
+ Response.new(
61
+ status: response.code.to_i,
62
+ headers: flatten_headers(response),
63
+ body: response.body
64
+ )
65
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
66
+ # A dead route that silently drops packets is indistinguishable from a
67
+ # slow one, so every phase is bounded. Surfaced as a retryable 599.
68
+ Response.new(status: 599, headers: {}, body: "#{e.class}: #{e.message}")
69
+ rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
70
+ drop_connection(uri)
71
+ Response.new(status: 599, headers: {}, body: "#{e.class}: #{e.message}")
72
+ end
73
+
74
+ # Closes every cached connection held by the current fiber.
75
+ #
76
+ # @return [void]
77
+ def close
78
+ connections.each_value { |http| http.finish if http.started? }
79
+ connections.clear
80
+ end
81
+
82
+ private
83
+
84
+ def build_request(request, uri)
85
+ klass = case request.verb.to_s.upcase
86
+ when "GET" then Net::HTTP::Get
87
+ when "POST" then Net::HTTP::Post
88
+ when "PATCH" then Net::HTTP::Patch
89
+ when "PUT" then Net::HTTP::Put
90
+ when "DELETE" then Net::HTTP::Delete
91
+ else raise ArgumentError, "unsupported method #{request.verb}"
92
+ end
93
+
94
+ net_request = klass.new(uri.request_uri)
95
+ (request.headers || {}).each { |key, value| net_request[key] = value }
96
+ net_request.body = request.body if request.body
97
+ net_request
98
+ end
99
+
100
+ def connection_for(uri)
101
+ key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
102
+
103
+ cached = connections[key]
104
+ return cached if cached&.started?
105
+
106
+ http = Net::HTTP.new(uri.host, uri.port)
107
+ http.use_ssl = uri.scheme == "https"
108
+ http.open_timeout = @open_timeout
109
+ http.read_timeout = @read_timeout
110
+ http.write_timeout = @write_timeout
111
+ http.keep_alive_timeout = 30
112
+ http.start
113
+
114
+ connections[key] = http
115
+ end
116
+
117
+ def drop_connection(uri)
118
+ key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
119
+ http = connections.delete(key)
120
+ http.finish if http&.started?
121
+ rescue IOError
122
+ # Already closed; nothing to do.
123
+ end
124
+
125
+ # Fiber-local, and therefore also thread-local: every thread has a root
126
+ # fiber, so this is strictly narrower than Thread.current storage.
127
+ def connections
128
+ Fiber[:discord_store_connections] ||= {}
129
+ end
130
+
131
+ def flatten_headers(response)
132
+ response.each_header.to_h { |key, value| [key.downcase, value] }
133
+ end
134
+ end
135
+
136
+ # Builds a multipart/form-data body the way Discord's attachment endpoints
137
+ # expect it: a +payload_json+ part carrying the message, plus one part per
138
+ # file named files[n].
139
+ module Multipart
140
+ module_function
141
+
142
+ # @param payload [String] the JSON message payload
143
+ # @param files [Array<Hash>] each {filename:, content:, content_type:}
144
+ # @return [Array(String, String)] content type and body
145
+ def encode(payload, files)
146
+ boundary = "----discordstore#{SecureRandom.hex(16)}"
147
+ body = +""
148
+
149
+ body << "--#{boundary}\r\n"
150
+ body << "Content-Disposition: form-data; name=\"payload_json\"\r\n"
151
+ body << "Content-Type: application/json\r\n\r\n"
152
+ body << payload
153
+ body << "\r\n"
154
+
155
+ files.each_with_index do |file, index|
156
+ body << "--#{boundary}\r\n"
157
+ body << "Content-Disposition: form-data; name=\"files[#{index}]\"; " \
158
+ "filename=\"#{sanitize(file[:filename])}\"\r\n"
159
+ body << "Content-Type: #{file[:content_type] || "application/octet-stream"}\r\n\r\n"
160
+ body << file[:content].to_s.dup.force_encoding(Encoding::BINARY)
161
+ body << "\r\n"
162
+ end
163
+
164
+ body << "--#{boundary}--\r\n"
165
+
166
+ ["multipart/form-data; boundary=#{boundary}", body]
167
+ end
168
+
169
+ def sanitize(filename)
170
+ filename.to_s.gsub(/["\r\n\\]/, "_")
171
+ end
172
+ end
173
+ end
174
+ end