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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/lib/active_record/connection_adapters/discord_adapter.rb +271 -0
- data/lib/active_storage/service/discord_service.rb +232 -0
- data/lib/discord_store/blob_store.rb +453 -0
- data/lib/discord_store/channel_shard.rb +73 -0
- data/lib/discord_store/cipher.rb +195 -0
- data/lib/discord_store/codec.rb +231 -0
- data/lib/discord_store/configuration.rb +197 -0
- data/lib/discord_store/errors.rb +92 -0
- data/lib/discord_store/guild_limits.rb +117 -0
- data/lib/discord_store/journal.rb +262 -0
- data/lib/discord_store/kv.rb +387 -0
- data/lib/discord_store/log/record.rb +88 -0
- data/lib/discord_store/log.rb +299 -0
- data/lib/discord_store/railtie.rb +27 -0
- data/lib/discord_store/replay.rb +136 -0
- data/lib/discord_store/snowflake.rb +109 -0
- data/lib/discord_store/tasks.rake +92 -0
- data/lib/discord_store/transport/bucket.rb +141 -0
- data/lib/discord_store/transport/fake.rb +394 -0
- data/lib/discord_store/transport/http.rb +174 -0
- data/lib/discord_store/transport/quota.rb +138 -0
- data/lib/discord_store/transport/rate_limiter.rb +148 -0
- data/lib/discord_store/transport/rest.rb +361 -0
- data/lib/discord_store/version.rb +5 -0
- data/lib/discord_store.rb +125 -0
- metadata +97 -0
|
@@ -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
|