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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ # Base class for every error this library raises.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the library is used before it has been configured, or when a
8
+ # configuration value is missing or nonsensical.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when the caller has not acknowledged that this library operates in
12
+ # violation of the Discord Developer Terms of Service. See
13
+ # {DiscordStore::Configuration#i_understand_this_violates_discord_tos}.
14
+ class UnacknowledgedError < Error
15
+ DEFAULT_MESSAGE = <<~MSG
16
+ discord_store stores application data in Discord messages and attachments.
17
+ That is an explicit violation of the Discord Developer Terms of Service and
18
+ the Discord API Developer Policy, and it can get your bot token revoked and
19
+ your account actioned without warning.
20
+
21
+ This library will not talk to Discord until you say, in code, that you know
22
+ that:
23
+
24
+ DiscordStore.configure do |c|
25
+ c.i_understand_this_violates_discord_tos = true
26
+ end
27
+
28
+ Do not set this in an application you did not build for yourself.
29
+ MSG
30
+
31
+ def initialize(msg = DEFAULT_MESSAGE)
32
+ super
33
+ end
34
+ end
35
+
36
+ # Base class for anything that came back from Discord's REST API.
37
+ class APIError < Error
38
+ attr_reader :status, :code, :response_body
39
+
40
+ def initialize(message, status: nil, code: nil, response_body: nil)
41
+ @status = status
42
+ @code = code
43
+ @response_body = response_body
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ # 401/403 — the token is wrong, expired, or lacks the required permission.
49
+ class AuthError < APIError; end
50
+
51
+ # 404 — the channel, message, or attachment is gone. In a store built on a
52
+ # chat app this is a routine occurrence, not an exceptional one: a human with
53
+ # Manage Messages can delete your data at any time from the client UI.
54
+ class NotFoundError < APIError; end
55
+
56
+ # 429 — rate limited. Carries the server-supplied backoff so callers can obey
57
+ # it rather than guessing.
58
+ class RateLimitedError < APIError
59
+ attr_reader :retry_after, :scope, :global
60
+
61
+ def initialize(message, retry_after:, scope: nil, global: false, **kwargs)
62
+ @retry_after = retry_after
63
+ @scope = scope
64
+ @global = global
65
+ super(message, **kwargs)
66
+ end
67
+ end
68
+
69
+ # A request exhausted its retry budget.
70
+ class ExhaustedError < Error; end
71
+
72
+ # The local process could not acquire rate-limit quota within the configured
73
+ # timeout. This is the moral equivalent of ActiveRecord::ConnectionTimeoutError,
74
+ # but the exhausted resource is request budget, not sockets.
75
+ class QuotaTimeoutError < Error; end
76
+
77
+ # A stored payload could not be decrypted or did not authenticate.
78
+ class DecryptionError < Error; end
79
+
80
+ # A stored payload is structurally wrong — truncated, wrong version, or not
81
+ # something this library wrote.
82
+ class CorruptRecordError < Error; end
83
+
84
+ # A record was larger than anything this library can represent, even after
85
+ # spilling to an attachment.
86
+ class PayloadTooLargeError < Error; end
87
+
88
+ # Raised when a message that was expected to be one of ours turns out to have
89
+ # been written by somebody else. discord_store only ever reads its own bot's
90
+ # messages; see DiscordStore::Transport::REST#assert_own_message!
91
+ class ForeignMessageError < Error; end
92
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ # Discovers how large an attachment this guild will actually accept.
5
+ #
6
+ # Every previous library in this genre hardcoded the number, and every one of
7
+ # them broke, because Discord has moved it repeatedly and in both directions:
8
+ # 8 MiB for years, then 25 MB, then back to 10 MB, with boosted guilds on a
9
+ # different ladder again. Published write-ups from 2023 and 2026 disagree,
10
+ # and both were right when they were written.
11
+ #
12
+ # So the number is treated as a fact about the running system rather than a
13
+ # constant. The tier table below is a conservative starting point; {#probe!}
14
+ # measures the truth by trying.
15
+ class GuildLimits
16
+ MIB = 1024 * 1024
17
+
18
+ # Deliberately pessimistic. Being wrong low costs a few extra chunks; being
19
+ # wrong high costs a failed upload halfway through a large file.
20
+ TIER_LIMITS = {
21
+ 0 => 10 * MIB,
22
+ 1 => 10 * MIB,
23
+ 2 => 50 * MIB,
24
+ 3 => 100 * MIB
25
+ }.freeze
26
+
27
+ FALLBACK_LIMIT = 8 * MIB
28
+
29
+ # Room for multipart framing, the payload_json part, and headers, so that a
30
+ # chunk sized at exactly the ceiling does not push the request over it.
31
+ REQUEST_OVERHEAD = 16 * 1024
32
+
33
+ def initialize(rest:, config:)
34
+ @rest = rest
35
+ @config = config
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ # Largest attachment this guild accepts, in bytes.
40
+ #
41
+ # @param refresh [Boolean]
42
+ # @return [Integer]
43
+ def attachment_limit(refresh: false)
44
+ @mutex.synchronize do
45
+ @attachment_limit = nil if refresh
46
+ @attachment_limit ||= discover_limit
47
+ end
48
+ end
49
+
50
+ # Bytes per blob chunk: the attachment ceiling less request overhead, unless
51
+ # the caller pinned it in configuration.
52
+ #
53
+ # @return [Integer]
54
+ def chunk_size
55
+ @config.chunk_size || (attachment_limit - REQUEST_OVERHEAD)
56
+ end
57
+
58
+ # Measures the real ceiling by binary search, uploading and then deleting
59
+ # throwaway attachments.
60
+ #
61
+ # Costs a handful of requests and writes garbage into the channel briefly,
62
+ # so it is opt-in. Run it once at deploy time and pin the result in
63
+ # configuration rather than probing on every boot.
64
+ #
65
+ # @param channel_id [String] a channel safe to write junk into
66
+ # @param low [Integer] known-good size
67
+ # @param high [Integer] known-or-suspected-bad size
68
+ # @param precision [Integer] stop when the bracket is this narrow
69
+ # @return [Integer] the largest size that succeeded
70
+ def probe!(channel_id:, low: MIB, high: 100 * MIB, precision: MIB / 4)
71
+ raise ArgumentError, "low must be under high" unless low < high
72
+
73
+ best = nil
74
+
75
+ while high - low > precision
76
+ midpoint = low + ((high - low) / 2)
77
+
78
+ if upload_succeeds?(channel_id, midpoint)
79
+ best = midpoint
80
+ low = midpoint
81
+ else
82
+ high = midpoint
83
+ end
84
+ end
85
+
86
+ best ||= low
87
+ @mutex.synchronize { @attachment_limit = best }
88
+ best
89
+ end
90
+
91
+ private
92
+
93
+ def discover_limit
94
+ return @config.chunk_size + REQUEST_OVERHEAD if @config.chunk_size
95
+ return FALLBACK_LIMIT unless @config.guild_id
96
+
97
+ guild = @rest.get_guild(@config.guild_id)
98
+ tier = guild["premium_tier"].to_i
99
+ TIER_LIMITS.fetch(tier, FALLBACK_LIMIT)
100
+ rescue APIError
101
+ # A store that cannot read its own guild can still write small chunks.
102
+ FALLBACK_LIMIT
103
+ end
104
+
105
+ def upload_succeeds?(channel_id, size)
106
+ message = @rest.create_message(
107
+ channel_id,
108
+ content: "DS1 probe #{size}",
109
+ files: [{ filename: "probe.bin", content: "\0" * size, content_type: "application/octet-stream" }]
110
+ )
111
+ @rest.delete_message(channel_id, message["id"])
112
+ true
113
+ rescue APIError, ExhaustedError
114
+ false
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "bigdecimal"
5
+ require "time"
6
+
7
+ module DiscordStore
8
+ # The write-ahead log behind the ActiveRecord adapter.
9
+ #
10
+ # Each entry is a SQL statement plus its bind values, which is logical
11
+ # replication in the plainest possible form: replaying the statements in order
12
+ # against an empty database reproduces the database. Postgres and MySQL both
13
+ # ship a version of this idea; the only novelty here is that the log segment
14
+ # is a Discord channel.
15
+ #
16
+ # Statements are journalled with their binds already materialised, because a
17
+ # prepared statement handle means nothing to a reader on another machine three
18
+ # months from now.
19
+ class Journal
20
+ STREAM = "wal"
21
+ CURSOR_TABLE = "discord_store_journal"
22
+
23
+ MODES = %i[sync async off].freeze
24
+
25
+ # Tags for values JSON cannot carry losslessly.
26
+ BINARY_TAG = "__b"
27
+ TIME_TAG = "__t"
28
+ DATE_TAG = "__D"
29
+ DECIMAL_TAG = "__d"
30
+
31
+ attr_reader :mode, :log
32
+
33
+ # @param config [Configuration]
34
+ # @param mode [Symbol] :sync, :async or :off
35
+ # @param flush_interval [Numeric] seconds, for :async
36
+ # @param max_buffer [Integer] entries, for :async
37
+ # @param http [#call, nil] injectable transport
38
+ def initialize(config:, mode: :sync, flush_interval: 1.0, max_buffer: 200, http: nil)
39
+ raise ConfigurationError, "journal_mode must be one of #{MODES.join(", ")}" unless MODES.include?(mode)
40
+
41
+ @mode = mode
42
+ @flush_interval = flush_interval
43
+ @max_buffer = max_buffer
44
+ @buffer = []
45
+ @mutex = Mutex.new
46
+ @local_cursor = nil
47
+
48
+ return if mode == :off
49
+
50
+ @client = Client.new(config: config, http: http)
51
+ @log = @client.log
52
+ start_flusher if mode == :async
53
+ end
54
+
55
+ # @return [Boolean]
56
+ def recording? = @mode != :off
57
+
58
+ # Records one transaction.
59
+ #
60
+ # @param statements [Array<Hash>] each {"sql" =>, "binds" =>}
61
+ # @return [String, nil] the cursor the transaction landed at, if written now
62
+ def write(statements)
63
+ return nil unless recording?
64
+ return nil if statements.empty?
65
+
66
+ entry = { "tx" => statements, "at" => Time.now.utc.iso8601(3) }
67
+
68
+ case @mode
69
+ when :sync
70
+ append([entry]).last&.cursor
71
+ when :async
72
+ buffered = @mutex.synchronize do
73
+ @buffer << entry
74
+ @buffer.size
75
+ end
76
+ flush! if buffered >= @max_buffer
77
+ nil
78
+ end
79
+ end
80
+
81
+ # Writes anything buffered.
82
+ #
83
+ # @return [Integer] entries written
84
+ def flush!
85
+ return 0 unless @mode == :async
86
+
87
+ pending = @mutex.synchronize do
88
+ drained = @buffer
89
+ @buffer = []
90
+ drained
91
+ end
92
+ return 0 if pending.empty?
93
+
94
+ append(pending)
95
+ pending.size
96
+ end
97
+
98
+ # @return [Integer] entries waiting to be written
99
+ def pending_count = @mutex.synchronize { @buffer.size }
100
+
101
+ # @return [String, nil] cursor of the newest record in the channel
102
+ def tip
103
+ return nil unless recording?
104
+
105
+ @log.tip(stream: STREAM)
106
+ end
107
+
108
+ # Reads transactions in order.
109
+ #
110
+ # @param from [String, nil] exclusive cursor
111
+ # @yieldparam entry [Hash] {"tx" =>, "at" =>}
112
+ # @yieldparam cursor [String]
113
+ # @return [void]
114
+ def each_transaction(from: nil)
115
+ # Deliberately checks for a log rather than for recording?: a replay reads
116
+ # while paused, and gating this on the write mode would make it a no-op
117
+ # exactly when it matters.
118
+ return if @log.nil?
119
+
120
+ @log.each(stream: STREAM, after: from) do |record|
121
+ data = record.data
122
+ next unless data.is_a?(Hash) && data["tx"]
123
+
124
+ yield data, record.cursor
125
+ end
126
+ end
127
+
128
+ # How far the local database has been replayed.
129
+ #
130
+ # @return [String, nil]
131
+ attr_accessor :local_cursor
132
+
133
+ # Suspends writing for the duration of the block, leaving reads working.
134
+ #
135
+ # Replay uses this: applying a statement locally must not journal it back,
136
+ # or every replay would duplicate the log it just read.
137
+ #
138
+ # @return [Object] the block's value
139
+ def while_paused
140
+ previous = @mode
141
+ @mode = :off
142
+ yield
143
+ ensure
144
+ @mode = previous
145
+ end
146
+
147
+ # @return [void]
148
+ def close
149
+ flush!
150
+ @flusher&.kill
151
+ @client&.close
152
+ end
153
+
154
+ # --- Bind serialisation ---------------------------------------------------
155
+
156
+ # Turns ActiveRecord bind parameters into something JSON can carry, with
157
+ # tags for the types it cannot.
158
+ #
159
+ # @param binds [Array]
160
+ # @return [Array]
161
+ def self.serialize_binds(binds)
162
+ Array(binds).map do |bind|
163
+ value = bind.respond_to?(:value_for_database) ? bind.value_for_database : bind
164
+ serialize_value(value)
165
+ end
166
+ end
167
+
168
+ # @param value [Object]
169
+ # @return [Object]
170
+ def self.serialize_value(value)
171
+ case value
172
+ when nil, true, false, Integer, Float then value
173
+ when BigDecimal then { DECIMAL_TAG => value.to_s("F") }
174
+ when Time then { TIME_TAG => value.utc.iso8601(6) }
175
+ when DateTime then { TIME_TAG => value.to_time.utc.iso8601(6) }
176
+ when Date then { DATE_TAG => value.iso8601 }
177
+ when String
178
+ # A UTF-8 string rides as itself; anything else is bytes, and bytes do
179
+ # not survive JSON.
180
+ if value.encoding == Encoding::BINARY || !value.valid_encoding?
181
+ { BINARY_TAG => Base64.strict_encode64(value) }
182
+ else
183
+ value
184
+ end
185
+ else
186
+ if value.respond_to?(:to_s) && value.class.name.to_s.include?("Binary")
187
+ { BINARY_TAG => Base64.strict_encode64(value.to_s) }
188
+ else
189
+ value.to_s
190
+ end
191
+ end
192
+ end
193
+
194
+ # @param values [Array]
195
+ # @return [Array]
196
+ def self.deserialize_binds(values)
197
+ Array(values).map { |value| deserialize_value(value) }
198
+ end
199
+
200
+ # @param value [Object]
201
+ # @return [Object]
202
+ def self.deserialize_value(value)
203
+ return value unless value.is_a?(Hash)
204
+
205
+ if value.key?(BINARY_TAG) then Base64.strict_decode64(value[BINARY_TAG])
206
+ elsif value.key?(TIME_TAG) then Time.iso8601(value[TIME_TAG])
207
+ elsif value.key?(DATE_TAG) then Date.iso8601(value[DATE_TAG])
208
+ elsif value.key?(DECIMAL_TAG) then BigDecimal(value[DECIMAL_TAG])
209
+ else value
210
+ end
211
+ end
212
+
213
+ # Builds a {Configuration} from a database.yml stanza.
214
+ #
215
+ # @param options [Hash]
216
+ # @return [Configuration]
217
+ def self.build_configuration(options)
218
+ Configuration.new.tap do |config|
219
+ config.i_understand_this_violates_discord_tos =
220
+ options[:i_understand_this_violates_discord_tos]
221
+ config.token = options[:token]
222
+ config.application_id = options[:application_id]
223
+ config.guild_id = options[:guild_id]
224
+ config.secret_key = options[:secret_key]
225
+ config.cipher = (options[:cipher] || :aes_256_gcm).to_sym
226
+ config.log_channel_ids = Array(options[:log_channel_ids]).map(&:to_s)
227
+ config.logger = options[:logger]
228
+
229
+ if config.log_channel_ids.size > 1
230
+ raise ConfigurationError,
231
+ "a write-ahead log needs one total order, and message IDs are only ordered " \
232
+ "within a channel. Configure exactly one log_channel_id for the adapter, or " \
233
+ "use DiscordStore::Log directly if per-stream ordering is enough."
234
+ end
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ def append(entries)
241
+ records = entries.map { |entry| Log::Record.new(stream: STREAM, data: entry) }
242
+ @log.append_all(records)
243
+ end
244
+
245
+ def start_flusher
246
+ @flusher = Thread.new do
247
+ Thread.current.name = "discord_store-journal-flusher"
248
+ loop do
249
+ sleep(@flush_interval)
250
+ begin
251
+ flush!
252
+ rescue StandardError => e
253
+ warn "discord_store: journal flush failed: #{e.class}: #{e.message}"
254
+ end
255
+ end
256
+ end
257
+ @flusher.abort_on_exception = false
258
+
259
+ at_exit { flush! }
260
+ end
261
+ end
262
+ end