discord_store 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: beaeca859e4b101de87ff0fd0c779311606c36bf800f2ed766066bbe868f4847
4
- data.tar.gz: 3b2dc1bead35992ed63ba051a32e7867b19bb0c6e50e3c02dd3977ab12fe5902
3
+ metadata.gz: f10dcdd64b9bd9de7e97d25e7d8281cc44a0810cb7a1925a638a89bfbba20fd6
4
+ data.tar.gz: 68b9779ba8f103aea3d34393fe82f6dadb745c6bda5e93a47d9231be9d5d39b2
5
5
  SHA512:
6
- metadata.gz: 14516705bd0e8d8af2272b6383de32c9faa3497b3f3b3f9dc1468732c5fa84f34f77da5b9d50d81259354e804b93f0aa6fff38eea6e738c5a972d26eadb3132b
7
- data.tar.gz: fe1d4aee25808ee3c4be6372e3e953c557f806812055a79eae16d57e2dad5624ae5637a80f0407479abce31e61c967061332fadb4dd0a2ff91e154a10b28c4c8
6
+ metadata.gz: 2e06b2344608460af2a506a210aa97f0d2ce28c8d80d911296ffcc4719d5d469389d1cbef201fcd6d3e7de6575bb06eb81263176a48934647deca3d1efe0b815
7
+ data.tar.gz: ebe9a1c48408f3a2a8a055ebd8bca5ab653ee0fa6a873a091198229b6efa277dbcca8c605d7d522ba82cdbe2863345bdc394552e27e40460c69476c50515a686
data/CHANGELOG.md CHANGED
@@ -3,6 +3,40 @@
3
3
  All notable changes to this project are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.2.0] - 2026-09-09
7
+
8
+ ### Added
9
+
10
+ - **`DiscordStore::Search`** — `client.search`, a wrapper over
11
+ `GET /guilds/{id}/messages/search`. Discord does have a query API; this
12
+ exposes it deliberately and uses it for almost nothing, because it is
13
+ documented to under-return, is eventually consistent, caps a query at ten
14
+ thousand rows, and cannot see through AES-256-GCM anyway. SQLite remains the
15
+ query layer. Results come back as a `Result` rather than an Array, because an
16
+ Array invites the `.size` that Discord says not to trust.
17
+ - **`BlobStore#orphans`** — chunks that no live manifest points at, the residue
18
+ of an interrupted write or a half-finished delete. Invisible to every other
19
+ method, because every other method starts from a manifest. This is the query
20
+ that justified the interface.
21
+ - **`rake discord:orphans`**, and a search line in `rake discord:doctor` that
22
+ distinguishes "no intent" from "broken".
23
+ - **`search_retry_floor`** configuration, so an unindexed guild answering
24
+ `retry_after: 0` does not become a hot loop.
25
+
26
+ ### Fixed
27
+
28
+ - A comment in `blob_store.rb` claimed "Discord gives bots no search". It does;
29
+ the endpoint is documented and requires the `MESSAGE_CONTENT` privileged
30
+ intent. The README carried a softer version of the same error.
31
+
32
+ ### Security
33
+
34
+ - Every search pins `author_id` to this application before the request leaves,
35
+ and filters the response again on the way in. Searching for another author
36
+ raises `SearchScopeError` while `own_messages_only` is on. Search reads across
37
+ a guild rather than a channel it was handed, which makes it the one endpoint
38
+ here that could turn a storage backend into a scraper.
39
+
6
40
  ## [0.1.0] - 2026-09-09
7
41
 
8
42
  First release.
@@ -57,4 +91,5 @@ Not verified against live Discord. The attachment ceiling, CDN `Range` support
57
91
  and real throughput are discovered at runtime rather than hardcoded, and none of
58
92
  them have been measured against a real guild.
59
93
 
94
+ [0.2.0]: https://github.com/chayuto/discord_store/releases/tag/v0.2.0
60
95
  [0.1.0]: https://github.com/chayuto/discord_store/releases/tag/v0.1.0
data/README.md CHANGED
@@ -51,9 +51,16 @@ def own_message?(message)
51
51
  end
52
52
  ```
53
53
 
54
- There is no message search, no member enumeration, no history export, and there
55
- will not be. The distance between a storage backend and a scraper is exactly
56
- whether it can read other people's messages, and this one cannot.
54
+ There is no member enumeration and no history export, and there will not be.
55
+
56
+ Discord *does* offer message search, and this library uses it in exactly one
57
+ place — finding its own orphaned attachments. Every search pins `author_id` to
58
+ this application, so the request cannot come back with somebody else's messages
59
+ in the first place, and the results are filtered again on the way in. Asking for
60
+ another author raises rather than being honoured.
61
+
62
+ The distance between a storage backend and a scraper is exactly whether it can
63
+ read other people's messages, and this one cannot.
57
64
 
58
65
  ---
59
66
 
@@ -168,6 +175,11 @@ CI runs every supported combination — ten of them — on every push.
168
175
  gem "discord_store"
169
176
  ```
170
177
 
178
+ ```sh
179
+ gem install discord_store # no Bundler, no Rails: the log and blob store
180
+ # work on their own
181
+ ```
182
+
171
183
  The version number is an honest 0. Three values in here are discovered at
172
184
  runtime rather than hardcoded — the real attachment ceiling, whether the CDN
173
185
  honours HTTP `Range`, and sharded throughput — and none of them has been
@@ -352,6 +364,67 @@ a.value # => 2
352
364
 
353
365
  ---
354
366
 
367
+ ## Discord's search API, and why reads still do not use it
368
+
369
+ Discord has a query API. `GET /guilds/{id}/messages/search` takes a full-text
370
+ `content` filter, channel and author filters, snowflake bounds, attachment
371
+ filename and extension filters, and sorts by relevance or time. That is more of
372
+ a query interface than a chat platform owes anybody, and it is worth being clear
373
+ that this library's architecture is not a workaround for its absence.
374
+
375
+ It is still not the read path, for four reasons that come from Discord's own
376
+ documentation rather than from taste:
377
+
378
+ 1. **It is allowed to under-return.** *"Search may return slightly fewer results
379
+ than the limit specified"*, and clients *"should not rely on the length of
380
+ the messages array to paginate results"*. `total_results` may also be wrong
381
+ while messages are being written. An index that silently drops rows gives you
382
+ wrong answers, not slow ones — and a query layer that is occasionally wrong
383
+ is worse than no query layer, because you cannot tell which answers to doubt.
384
+ 2. **It is eventually consistent.** Fresh messages return `202` with
385
+ `{"code": 110000, "retry_after": n}` until indexed. There is no
386
+ read-your-writes.
387
+ 3. **It pages at 25 and cannot offset past 9975** — ten thousand rows per query,
388
+ reachable only in four hundred round trips.
389
+ 4. **Everything stored here is ciphertext.** Discord's index tokenises words;
390
+ AES-256-GCM output has none. Making payloads searchable would mean storing
391
+ them in the clear on somebody else's servers, which is a far larger
392
+ concession than the one on the front of this README.
393
+
394
+ So it is used for exactly one thing, which a channel scan answers badly:
395
+
396
+ ```ruby
397
+ client.blobs.orphans
398
+ # => [{ message_id: "...", channel_id: "...", filename: "0.ds1", size: 8388608 }]
399
+ ```
400
+
401
+ A blob is a manifest plus its chunks, written in that order. Interrupt the
402
+ middle — a crash, a rate limit that outlived its retries — and the chunks are
403
+ invisible to every other method here, because they all start from a manifest,
404
+ while still counting against the guild forever. Search finds them by matching
405
+ `.ds1`, an extension that was already public and says nothing about contents.
406
+
407
+ ```sh
408
+ rake discord:orphans
409
+ ```
410
+
411
+ Needs the `MESSAGE_CONTENT` privileged intent, which is off by default. Nothing
412
+ else in the library needs it, and `rake discord:doctor` reports its absence as a
413
+ missing capability rather than a failure.
414
+
415
+ **The safety pin.** Search reads across a guild rather than a channel it was
416
+ handed, which makes it the one endpoint here that could turn a storage backend
417
+ into a scraper. Every search pins `author_id` to this application before the
418
+ request leaves, so other people's messages cannot come back in the first place,
419
+ and the response is filtered again on arrival. Asking for another author raises:
420
+
421
+ ```ruby
422
+ client.search.messages(author_id: ["999..."])
423
+ # => DiscordStore::SearchScopeError
424
+ ```
425
+
426
+ ---
427
+
355
428
  ## What does not work
356
429
 
357
430
  - **No server-side query.** Anything not in the local view means scanning a
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "set"
4
5
  require "stringio"
5
6
 
6
7
  module DiscordStore
@@ -109,6 +110,47 @@ module DiscordStore
109
110
  manifest
110
111
  end
111
112
 
113
+ # Chunks that no live manifest points at.
114
+ #
115
+ # A blob is a manifest plus its chunks, written in that order and deleted in
116
+ # the other. Anything that interrupts the middle -- a crash, a rate limit
117
+ # that outlived its retries, a delete that got halfway -- leaves chunks with
118
+ # nothing referring to them. They are invisible to every other method here,
119
+ # because every other method starts from a manifest, and they count against
120
+ # the guild's storage forever.
121
+ #
122
+ # Finding them means asking a question no channel scan answers well: which
123
+ # attachments exist that nothing references. Discord's search can answer it
124
+ # directly, filtering on an extension that is already public and says
125
+ # nothing about the contents.
126
+ #
127
+ # Costs one search page per 25 chunks and needs the MESSAGE_CONTENT intent.
128
+ #
129
+ # @param search [Search, nil] defaults to one built from this config
130
+ # @return [Array<Hash>] each {message_id:, channel_id:, filename:, size:}
131
+ # @raise [MissingIntentError] if the privileged intent is not enabled
132
+ def orphans(search: nil)
133
+ finder = search || Search.new(rest: @rest, config: @config)
134
+ referenced = referenced_message_ids
135
+
136
+ found = []
137
+ finder.each(has: "file",
138
+ attachment_extension: Search::EXTENSION,
139
+ channel_ids: @config.blob_channel_ids) do |message|
140
+ next if referenced.include?(message["id"].to_s)
141
+
142
+ Array(message["attachments"]).each do |attachment|
143
+ # Spilled log records share the extension and are not blob chunks.
144
+ next if attachment["filename"].to_s == Codec::SPILL_FILENAME
145
+
146
+ found << { message_id: message["id"].to_s, channel_id: message["channel_id"].to_s,
147
+ filename: attachment["filename"].to_s, size: attachment["size"].to_i }
148
+ end
149
+ end
150
+
151
+ found
152
+ end
153
+
112
154
  # @param key [String]
113
155
  # @return [String] the blob's bytes
114
156
  # @raise [NotFoundError]
@@ -367,6 +409,16 @@ module DiscordStore
367
409
  end
368
410
  end
369
411
 
412
+ def referenced_message_ids
413
+ @index.warm!
414
+ @index.keys.each_with_object(Set.new) do |key, ids|
415
+ manifest = @index.get(key)
416
+ next if manifest.nil?
417
+
418
+ manifest.chunks.each { |chunk| ids << chunk[:message_id].to_s }
419
+ end
420
+ end
421
+
370
422
  def safe_delete(channel_id, message_id)
371
423
  @rest.delete_message(channel_id, message_id)
372
424
  rescue APIError
@@ -375,10 +427,13 @@ module DiscordStore
375
427
 
376
428
  # Maps blob keys to manifests.
377
429
  #
378
- # Discord gives bots no search, so a lookup by key would otherwise mean
379
- # scanning a channel. This scans it exactly once, at first use, and keeps the
380
- # result. Manifests are one small message per blob, so the scan is a hundred
381
- # blobs per request.
430
+ # A lookup by key would otherwise mean scanning a channel. This scans it
431
+ # exactly once, at first use, and keeps the result. Manifests are one small
432
+ # message per blob, so the scan is a hundred blobs per request.
433
+ #
434
+ # Discord's message search could answer a key lookup directly, but only if
435
+ # the key were stored in plaintext, and a blob key names the thing it holds.
436
+ # Scanning once is cheaper than telling Discord what our files are called.
382
437
  #
383
438
  # Swap in your own if you would rather the index lived in Postgres: anything
384
439
  # answering get/put/delete/keys will do.
@@ -23,6 +23,11 @@ module DiscordStore
23
23
  global_rate_limit: 45,
24
24
  quota_timeout: 15.0,
25
25
  max_retries: 5,
26
+ # Discord answers 202 with retry_after 0 when it has no better estimate,
27
+ # and says to "retry after a short delay". This is what short means.
28
+ # Tests drop it so the suite exercises the retry rather than sleeping
29
+ # through it, which is the same reason the fake's rate limit is settable.
30
+ search_retry_floor: 0.5,
26
31
  open_timeout: 5.0,
27
32
  read_timeout: 30.0,
28
33
  write_timeout: 30.0,
@@ -85,7 +90,7 @@ module DiscordStore
85
90
 
86
91
  attr_accessor :global_rate_limit, :quota_timeout, :max_retries,
87
92
  :open_timeout, :read_timeout, :write_timeout,
88
- :user_agent, :api_base, :logger
93
+ :user_agent, :api_base, :logger, :search_retry_floor
89
94
 
90
95
  # Refuse to read any message not authored by +application_id+. This is the
91
96
  # line between a storage backend and a scraper, and it is enforced in code
@@ -89,4 +89,47 @@ module DiscordStore
89
89
  # been written by somebody else. discord_store only ever reads its own bot's
90
90
  # messages; see DiscordStore::Transport::REST#assert_own_message!
91
91
  class ForeignMessageError < Error; end
92
+
93
+ # Discord's message index had not caught up with the messages being searched
94
+ # for, and did not catch up within the allowed number of retries.
95
+ #
96
+ # This is not a failure so much as a statement about what search is: an index
97
+ # maintained asynchronously beside the messages, not the messages themselves.
98
+ # A write is durable the moment create_message returns; it is *findable* some
99
+ # time after that, and Discord does not promise when.
100
+ class IndexNotReadyError < Error
101
+ attr_reader :documents_indexed
102
+
103
+ def initialize(message = nil, documents_indexed: nil)
104
+ @documents_indexed = documents_indexed
105
+ super(message || "Discord's search index is not ready for this guild yet")
106
+ end
107
+ end
108
+
109
+ # Searching requires the MESSAGE_CONTENT privileged intent, which Discord
110
+ # grants per application and reviews by hand once a bot is in 100 guilds.
111
+ class MissingIntentError < Error
112
+ def initialize(message = nil)
113
+ super(message || <<~MSG.strip)
114
+ Discord refused the search request. GET /guilds/{id}/messages/search is
115
+ gated on the MESSAGE_CONTENT privileged intent, which is off by default
116
+ and has to be enabled for the application in the Developer Portal, under
117
+ Bot -> Privileged Gateway Intents. Past 100 guilds Discord reviews the
118
+ request by hand, and "I am using it as a database" is not a use case it
119
+ approves.
120
+
121
+ Everything else in this library works without the intent. Search is the
122
+ only part that needs it.
123
+ MSG
124
+ end
125
+ end
126
+
127
+ # An attempt to search outside the messages this bot wrote, while the
128
+ # own_messages_only guard is on.
129
+ #
130
+ # Search is the one endpoint in Discord's API that could turn this library
131
+ # into a scraper: it reads across a whole guild rather than a channel this bot
132
+ # was pointed at. The guard is enforced by pinning author_id to our own
133
+ # application, and this is what you get for trying to unpin it.
134
+ class SearchScopeError < Error; end
92
135
  end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordStore
4
+ # Discord's message index, exposed deliberately and used for almost nothing.
5
+ #
6
+ # Discord does have a query API. `GET /guilds/{id}/messages/search` takes a
7
+ # full-text `content` filter, channel and author filters, snowflake bounds,
8
+ # attachment filename and extension filters, and sorts by relevance or time.
9
+ # It is more of a query interface than a chat platform owes anybody.
10
+ #
11
+ # It is still not where this library reads from, for four reasons that are
12
+ # Discord's documentation rather than an opinion:
13
+ #
14
+ # 1. It is allowed to under-return. "Search may return slightly fewer
15
+ # results than the limit specified", and clients "should not rely on the
16
+ # length of the messages array to paginate". An index that silently drops
17
+ # rows gives you wrong answers, not slow ones, and a query layer that is
18
+ # sometimes wrong is worse than no query layer.
19
+ # 2. It is eventually consistent. Fresh messages answer 202 until indexed,
20
+ # so there is no read-your-writes.
21
+ # 3. It pages at 25 and cannot offset past 9975, capping any single query
22
+ # at ten thousand rows across four hundred round trips.
23
+ # 4. Everything this library stores is AES-256-GCM ciphertext. Discord's
24
+ # index tokenises words; there are none. Making the payloads searchable
25
+ # would mean storing them in the clear on somebody else's servers, which
26
+ # is a much larger concession than the one on the front of the README.
27
+ #
28
+ # So SQLite remains the query layer, and this exists for the things search is
29
+ # genuinely better at than a channel scan: finding our own messages across a
30
+ # guild without knowing which channel they are in. The metadata it matches on
31
+ # -- the +DS1+ framing prefix and the +.ds1+ attachment extension -- is
32
+ # plaintext already, and deliberately says nothing about the contents.
33
+ #
34
+ # @see DiscordStore::BlobStore#orphans for the reason this was built
35
+ class Search
36
+ # The plaintext framing prefix every message this library writes begins
37
+ # with. Not a secret, and not informative: it identifies the format, not the
38
+ # contents.
39
+ MARKER = "DS1"
40
+
41
+ # Attachment extension used by both blob chunks and spilled log records.
42
+ EXTENSION = "ds1"
43
+
44
+ # One page. Discord's own maximum.
45
+ PAGE_SIZE = Transport::REST::SEARCH_PAGE_LIMIT
46
+
47
+ # A page of results.
48
+ #
49
+ # Deliberately not a bare Array: +total_results+ is documented as
50
+ # inaccurate while messages are being written, and the number of messages
51
+ # returned is documented as possibly fewer than asked for. Handing back an
52
+ # array would invite exactly the +.size+ that Discord says not to trust.
53
+ Result = Struct.new(:messages, :total_results, :indexing, keyword_init: true) do
54
+ include Enumerable
55
+
56
+ def each(&) = messages.each(&)
57
+
58
+ # @return [Integer] how many came back, which is not how many exist
59
+ def size = messages.size
60
+
61
+ def empty? = messages.empty?
62
+
63
+ # Always true. Kept as a method so calling code reads honestly.
64
+ #
65
+ # @return [Boolean]
66
+ def approximate? = true
67
+
68
+ # @return [Boolean] whether Discord is still backfilling this guild
69
+ def indexing? = !indexing.nil? && indexing
70
+ end
71
+
72
+ attr_reader :config
73
+
74
+ # @param rest [Transport::REST]
75
+ # @param config [Configuration]
76
+ # @param guild_id [String, nil] defaults to the configured guild
77
+ def initialize(rest:, config:, guild_id: nil)
78
+ @rest = rest
79
+ @config = config
80
+ @guild_id = (guild_id || config.guild_id)&.to_s
81
+
82
+ raise ConfigurationError, "guild_id is required to search" if @guild_id.nil? || @guild_id.empty?
83
+ end
84
+
85
+ # Searches for messages this bot wrote.
86
+ #
87
+ # @param content [String, nil] full-text filter; only ever matches the
88
+ # plaintext framing, never a payload
89
+ # @param channel_ids [Array<String>, nil] restrict to these channels
90
+ # @param after [Time, String, Integer, nil] exclusive lower bound
91
+ # @param before [Time, String, Integer, nil] exclusive upper bound
92
+ # @param has [Array<String>, String, nil] e.g. "file"
93
+ # @param attachment_extension [String, nil]
94
+ # @param attachment_filename [String, nil]
95
+ # @param pinned [Boolean, nil]
96
+ # @param sort_by [String, nil] "timestamp" or "relevance"
97
+ # @param sort_order [String, nil] "asc" or "desc"
98
+ # @param limit [Integer]
99
+ # @param offset [Integer]
100
+ # @param author_id [Array<String>, nil] refused while own_messages_only is on
101
+ # @return [Result]
102
+ def messages(content: nil, channel_ids: nil, after: nil, before: nil, has: nil,
103
+ attachment_extension: nil, attachment_filename: nil, pinned: nil,
104
+ sort_by: nil, sort_order: nil, limit: PAGE_SIZE, offset: 0, author_id: nil)
105
+ params = {
106
+ content: content,
107
+ channel_id: Array(channel_ids).map(&:to_s),
108
+ min_id: bound(after),
109
+ max_id: bound(before),
110
+ has: Array(has),
111
+ attachment_extension: Array(attachment_extension),
112
+ attachment_filename: Array(attachment_filename),
113
+ pinned: pinned,
114
+ sort_by: sort_by,
115
+ sort_order: sort_order,
116
+ limit: limit.to_i.clamp(1, PAGE_SIZE),
117
+ offset: assert_offset!(offset),
118
+ author_id: scope_authors(author_id)
119
+ }
120
+
121
+ body = @rest.search_messages(@guild_id, **params)
122
+
123
+ Result.new(
124
+ messages: body["messages"],
125
+ total_results: body["total_results"],
126
+ indexing: body["indexing"]
127
+ )
128
+ end
129
+
130
+ # Every message this library wrote, anywhere in the guild.
131
+ #
132
+ # @return [Result]
133
+ def ours(**options) = messages(content: MARKER, **options)
134
+
135
+ # Every attachment this library uploaded: blob chunks and spilled records.
136
+ #
137
+ # @return [Result]
138
+ def attachments(**options)
139
+ messages(has: "file", attachment_extension: EXTENSION, **options)
140
+ end
141
+
142
+ # Pages through results.
143
+ #
144
+ # Pages by +offset+ rather than by the size of the last page, because
145
+ # Discord says not to paginate on that size. Stops at the offset ceiling
146
+ # with an explicit error rather than quietly returning a truncated set.
147
+ #
148
+ # @yieldparam message [Hash]
149
+ # @return [Enumerator, void]
150
+ def each(**options, &block)
151
+ return enum_for(:each, **options) unless block
152
+
153
+ offset = options.delete(:offset).to_i
154
+ seen = 0
155
+
156
+ loop do
157
+ page = messages(**options, offset: offset, limit: PAGE_SIZE)
158
+ page.each(&block)
159
+
160
+ seen += page.size
161
+ offset += PAGE_SIZE
162
+
163
+ break if page.empty? || seen >= page.total_results
164
+ break if exhausted?(offset, page)
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def exhausted?(offset, page)
171
+ return false if offset <= Transport::REST::SEARCH_OFFSET_LIMIT
172
+
173
+ raise Error, <<~MSG.strip
174
+ Search cannot be offset past #{Transport::REST::SEARCH_OFFSET_LIMIT}, and this
175
+ query claims #{page.total_results} results. Narrow it with channel_ids or a
176
+ time range; there is no page #{offset}. This is a limit of Discord's index,
177
+ not of this library, and it is one of the reasons reads do not go through it.
178
+ MSG
179
+ end
180
+
181
+ # The safety pin.
182
+ #
183
+ # Search reads across a guild rather than a channel it was handed, so it is
184
+ # the one call in this library that could return somebody else's messages.
185
+ # Pinning author_id means the request cannot come back with them in the
186
+ # first place, rather than relying on filtering afterwards -- though
187
+ # {Transport::REST#normalise_search} filters afterwards as well.
188
+ def scope_authors(requested)
189
+ return Array(requested).map(&:to_s) unless config.own_messages_only
190
+
191
+ if requested && Array(requested).map(&:to_s) != [config.application_id.to_s]
192
+ raise SearchScopeError, <<~MSG.strip
193
+ Refusing to search for messages written by #{Array(requested).join(", ")}.
194
+ own_messages_only is on, which pins every search to this application's own
195
+ messages. Reading other people's messages is the line between a storage
196
+ backend and a scraper, and search is the endpoint that would cross it.
197
+ Set own_messages_only = false if you genuinely mean to, and know that
198
+ nothing else in this library will help you.
199
+ MSG
200
+ end
201
+
202
+ [config.application_id.to_s]
203
+ end
204
+
205
+ def assert_offset!(offset)
206
+ value = offset.to_i
207
+ return value if value <= Transport::REST::SEARCH_OFFSET_LIMIT
208
+
209
+ raise ArgumentError,
210
+ "offset #{value} exceeds Discord's maximum of #{Transport::REST::SEARCH_OFFSET_LIMIT}"
211
+ end
212
+
213
+ def bound(value)
214
+ return nil if value.nil?
215
+ return Snowflake.from_time(value).to_s if value.is_a?(Time)
216
+
217
+ value.to_s
218
+ end
219
+ end
220
+ end
@@ -1,5 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Reports whether search works, without making its absence look like a failure:
4
+ # the MESSAGE_CONTENT intent is off by default and everything except orphan
5
+ # detection works fine without it.
6
+ def report_search(client)
7
+ result = client.search.ours(limit: 1)
8
+ puts "search: available#{" (guild still indexing)" if result.indexing?}"
9
+ rescue DiscordStore::MissingIntentError
10
+ puts "search: unavailable (MESSAGE_CONTENT intent off; only rake discord:orphans needs it)"
11
+ rescue DiscordStore::IndexNotReadyError
12
+ puts "search: index not ready yet for this guild"
13
+ end
14
+
3
15
  namespace :discord do
4
16
  desc "Rebuild the local database from the Discord log"
5
17
  task replay: :environment do
@@ -75,6 +87,29 @@ namespace :discord do
75
87
 
76
88
  puts "attachment limit: #{client.limits.attachment_limit} bytes"
77
89
  puts "chunk size: #{client.limits.chunk_size} bytes"
90
+
91
+ report_search(client)
92
+ rescue DiscordStore::Error => e
93
+ abort "#{e.class}: #{e.message}"
94
+ end
95
+
96
+ desc "Find blob chunks that no manifest points at (needs MESSAGE_CONTENT)"
97
+ task orphans: :environment do
98
+ client = DiscordStore.client
99
+ found = client.blobs.orphans
100
+
101
+ if found.empty?
102
+ puts "no orphaned chunks"
103
+ else
104
+ bytes = found.sum { |o| o[:size] }
105
+ puts "#{found.size} orphaned chunk(s), #{bytes} bytes:"
106
+ found.each { |o| puts " #{o[:channel_id]}/#{o[:message_id]} #{o[:filename]} (#{o[:size]}b)" }
107
+ puts
108
+ puts "These are chunks whose manifest never landed or was half-deleted."
109
+ puts "Nothing references them and nothing else will find them."
110
+ end
111
+ rescue DiscordStore::MissingIntentError => e
112
+ abort e.message
78
113
  rescue DiscordStore::Error => e
79
114
  abort "#{e.class}: #{e.message}"
80
115
  end
@@ -36,6 +36,9 @@ module DiscordStore
36
36
  # Permissive by default so that tests exercise logic rather than sleep;
37
37
  # pass Discord's real per-channel window ({limit: 5, reset_after: 5.0})
38
38
  # when the point of the test is the limiter itself.
39
+ SEARCH_PAGE_SIZE = 25
40
+ SEARCH_OFFSET_CEILING = 9975
41
+
39
42
  def initialize(application_id: "111111111111111111", clock: -> { Time.now },
40
43
  rate_limit: { limit: 1000, reset_after: 0.05 })
41
44
  @application_id = application_id.to_s
@@ -47,6 +50,9 @@ module DiscordStore
47
50
  @requests = []
48
51
  @pending_rate_limits = 0
49
52
  @sequence = 0
53
+ @search_pending = 0
54
+ @search_under_returns = false
55
+ @search_denied = false
50
56
  @mutex = Mutex.new
51
57
  end
52
58
 
@@ -101,6 +107,32 @@ module DiscordStore
101
107
  # @return [Array<Hash>]
102
108
  def messages_in(channel_id) = @channels[channel_id.to_s]
103
109
 
110
+ # Makes the next +count+ searches answer 202 "index not yet available",
111
+ # which is what Discord does until it has indexed a guild.
112
+ #
113
+ # @return [void]
114
+ def delay_search_index(count: 1)
115
+ @search_pending = count
116
+ end
117
+
118
+ # Makes search return one fewer result per page than it should.
119
+ #
120
+ # Discord documents this: "search may return slightly fewer results than
121
+ # the limit specified". Code that paginates on the size of the returned
122
+ # array silently loses rows, so the fake does it too.
123
+ #
124
+ # @return [void]
125
+ def search_under_returns!
126
+ @search_under_returns = true
127
+ end
128
+
129
+ # Makes search answer 403, as it does without the MESSAGE_CONTENT intent.
130
+ #
131
+ # @return [void]
132
+ def deny_search!
133
+ @search_denied = true
134
+ end
135
+
104
136
  # @return [Integer] total API calls seen, for asserting on request counts
105
137
  def request_count = @requests.size
106
138
 
@@ -112,7 +144,10 @@ module DiscordStore
112
144
  def dispatch(request, uri)
113
145
  method = request.verb.to_s.upcase
114
146
  path = uri.path
115
- query = URI.decode_www_form(uri.query.to_s).to_h
147
+ pairs = URI.decode_www_form(uri.query.to_s)
148
+ # .to_h keeps only the last value for a repeated key, which is exactly
149
+ # what array query params are, so search gets the pairs instead.
150
+ query = pairs.to_h
116
151
 
117
152
  case method
118
153
  when "POST"
@@ -134,14 +169,16 @@ module DiscordStore
134
169
  not_found
135
170
  end
136
171
  when "GET"
137
- dispatch_get(path, query)
172
+ dispatch_get(path, query, pairs)
138
173
  else
139
174
  not_found
140
175
  end
141
176
  end
142
177
 
143
- def dispatch_get(path, query)
178
+ def dispatch_get(path, query, pairs = [])
144
179
  case path
180
+ when %r{/guilds/(\d+)/messages/search\z}
181
+ search_messages(::Regexp.last_match(1), pairs)
145
182
  when %r{/channels/(\d+)/messages/(\d+)\z}
146
183
  get_message(::Regexp.last_match(1), ::Regexp.last_match(2))
147
184
  when %r{/channels/(\d+)/messages\z}
@@ -245,13 +282,141 @@ module DiscordStore
245
282
  Response.new(status: 204, headers: rate_limit_headers, body: nil)
246
283
  end
247
284
 
285
+ # Discord's message search, including the parts that make it unsuitable
286
+ # as a read path. A generous fake here would let code ship that breaks
287
+ # the first time the index lags or drops a row.
288
+ # +guild_id+ is unused: the fake holds one guild's worth of channels, and
289
+ # the parameter is kept so the signature matches the endpoint it models.
290
+ def search_messages(_guild_id, pairs)
291
+ return forbidden("Missing Access") if @search_denied
292
+
293
+ if @search_pending.positive?
294
+ @search_pending -= 1
295
+ return index_not_ready
296
+ end
297
+
298
+ params = group_params(pairs)
299
+ matches = search_candidates(params)
300
+ window = search_window(matches, params)
301
+
302
+ ok({
303
+ "total_results" => matches.size,
304
+ "doing_deep_historical_index" => false,
305
+ # Nested one deep: the shape Discord kept after it stopped
306
+ # returning the surrounding context of each hit.
307
+ "messages" => window.map { |message| [message] }
308
+ })
309
+ end
310
+
311
+ def search_candidates(params)
312
+ all = @channels.flat_map { |channel_id, messages| messages.map { |m| [channel_id, m] } }
313
+
314
+ all.select { |channel_id, message| search_match?(channel_id, message, params) }
315
+ .map { |_, message| message }
316
+ .sort_by { |message| -message["id"].to_i }
317
+ end
318
+
319
+ def search_match?(channel_id, message, params)
320
+ search_scope_match?(channel_id, message, params) &&
321
+ search_bounds_match?(message, params) &&
322
+ search_body_match?(message, params)
323
+ end
324
+
325
+ def search_scope_match?(channel_id, message, params)
326
+ authors = params["author_id"]
327
+ channels = params["channel_id"]
328
+
329
+ return false if authors.any? && !authors.include?(message.dig("author", "id"))
330
+ return false if channels.any? && !channels.include?(channel_id)
331
+
332
+ true
333
+ end
334
+
335
+ def search_bounds_match?(message, params)
336
+ id = message["id"].to_i
337
+ min = params["min_id"].first
338
+ max = params["max_id"].first
339
+
340
+ return false if min && id <= min.to_i
341
+ return false if max && id >= max.to_i
342
+
343
+ true
344
+ end
345
+
346
+ def search_body_match?(message, params)
347
+ content = params["content"]
348
+
349
+ return false if content.any? && !search_content_match?(message, content)
350
+ return false if params["has"].include?("file") && Array(message["attachments"]).empty?
351
+
352
+ search_attachment_match?(message, params)
353
+ end
354
+
355
+ def search_content_match?(message, needles)
356
+ haystack = message["content"].to_s.downcase
357
+ needles.all? { |needle| haystack.include?(needle.to_s.downcase) }
358
+ end
359
+
360
+ def search_attachment_match?(message, params)
361
+ extensions = params["attachment_extension"]
362
+ filenames = params["attachment_filename"]
363
+ return true if extensions.empty? && filenames.empty?
364
+
365
+ names = Array(message["attachments"]).map { |a| a["filename"].to_s }
366
+ return false if extensions.any? && names.none? { |n| extensions.include?(n.split(".").last) }
367
+ return false if filenames.any? && names.none? { |n| filenames.include?(n) }
368
+
369
+ true
370
+ end
371
+
372
+ def search_window(matches, params)
373
+ limit = (params["limit"].first || SEARCH_PAGE_SIZE).to_i.clamp(1, SEARCH_PAGE_SIZE)
374
+ offset = (params["offset"].first || 0).to_i
375
+ return [] if offset > SEARCH_OFFSET_CEILING
376
+
377
+ window = matches.slice(offset, limit) || []
378
+ # "Search may return slightly fewer results than the limit specified."
379
+ window = window[0...-1] if @search_under_returns && window.size > 1
380
+ window
381
+ end
382
+
383
+ def group_params(pairs)
384
+ grouped = Hash.new { |hash, key| hash[key] = [] }
385
+ pairs.each { |key, value| grouped[key] << value }
386
+ grouped
387
+ end
388
+
389
+ def index_not_ready
390
+ Response.new(
391
+ status: 202,
392
+ headers: rate_limit_headers.merge("content-type" => "application/json"),
393
+ body: JSON.generate({
394
+ "message" => "Index not yet available. Try again later",
395
+ "code" => 110_000,
396
+ "documents_indexed" => 0,
397
+ "retry_after" => 0
398
+ })
399
+ )
400
+ end
401
+
402
+ def forbidden(message)
403
+ Response.new(
404
+ status: 403,
405
+ headers: rate_limit_headers.merge("content-type" => "application/json"),
406
+ body: JSON.generate({ "message" => message, "code" => 50_001 })
407
+ )
408
+ end
409
+
248
410
  def cdn_response(uri)
249
411
  segments = uri.path.split("/").reject(&:empty?)
250
412
  attachment_id = segments[2]
251
413
  record = @attachments[attachment_id]
252
414
  return not_found unless record
253
415
 
254
- query = URI.decode_www_form(uri.query.to_s).to_h
416
+ pairs = URI.decode_www_form(uri.query.to_s)
417
+ # .to_h keeps only the last value for a repeated key, which is exactly
418
+ # what array query params are, so search gets the pairs instead.
419
+ query = pairs.to_h
255
420
  expires_at = query["ex"].to_s.to_i(16)
256
421
 
257
422
  # The whole point of the fake: an expired link 404s, exactly as Discord's
@@ -9,15 +9,30 @@ module DiscordStore
9
9
  # The Discord REST surface this library uses, and nothing else.
10
10
  #
11
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.
12
+ # this bot itself wrote. There is no member enumeration and no history
13
+ # export: the difference between a storage backend and a scraper is whether
14
+ # it can read other people's messages, and that difference is enforced in
15
+ # {#assert_own_message!} rather than in a paragraph of the README.
16
+ #
17
+ # {#search_messages} is the one endpoint here that reads across a guild
18
+ # rather than a channel it was handed, which makes it the obvious way to
19
+ # turn this library into the thing it is not. It is therefore pinned to our
20
+ # own author id before the request leaves, and filtered again on the way
21
+ # back. See DiscordStore::Search.
17
22
  class REST
18
23
  MESSAGE_PAGE_LIMIT = 100
19
24
  BULK_DELETE_LIMIT = 100
20
25
 
26
+ # Search pages at 25, not 100, and cannot be offset past 9975. Together
27
+ # those cap a single query at ten thousand results, reachable only 25 at a
28
+ # time -- four hundred round trips to exhaust one query.
29
+ SEARCH_PAGE_LIMIT = 25
30
+ SEARCH_OFFSET_LIMIT = 9975
31
+
32
+ # Discord answers 202 with this code when the guild is not indexed yet.
33
+ INDEX_NOT_READY_CODE = 110_000
34
+ SEARCH_INDEX_RETRIES = 5
35
+
21
36
  # Discord refuses to bulk-delete messages older than two weeks. Past that
22
37
  # the only route is one request per message, which is why the default
23
38
  # delete policy is to tombstone instead.
@@ -183,6 +198,53 @@ module DiscordStore
183
198
 
184
199
  # --- Guild and channel metadata ----------------------------------------
185
200
 
201
+ # --- Search ------------------------------------------------------------
202
+
203
+ # Searches a guild's messages.
204
+ #
205
+ # Three things about this endpoint are worth knowing before trusting it,
206
+ # and all three are Discord's own documentation rather than opinion:
207
+ #
208
+ # * It is an index maintained beside the messages, not the messages. A
209
+ # write is durable when create_message returns and findable some
210
+ # unspecified time later; until then this answers 202.
211
+ # * It is allowed to under-return. "Search may return slightly fewer
212
+ # results than the limit specified", and total_results "may not be
213
+ # accurate" while messages are being written. Results are a lower
214
+ # bound, never a count.
215
+ # * It needs the MESSAGE_CONTENT privileged intent.
216
+ #
217
+ # @param guild_id [String]
218
+ # @param params [Hash] query string params; array values are repeated
219
+ # @return [Hash] {"messages" => [Hash], "total_results" => Integer,
220
+ # "indexing" => Boolean}
221
+ # @raise [IndexNotReadyError] the index never caught up
222
+ # @raise [MissingIntentError] the intent is not enabled
223
+ def search_messages(guild_id, **params)
224
+ route = "GET /guilds/#{guild_id}/messages/search"
225
+ attempt = 0
226
+
227
+ loop do
228
+ attempt += 1
229
+ body = search_request(guild_id, params, route)
230
+
231
+ return normalise_search(body) unless index_pending?(body)
232
+
233
+ if attempt > SEARCH_INDEX_RETRIES
234
+ raise IndexNotReadyError.new(
235
+ "#{route}: index still not ready after #{SEARCH_INDEX_RETRIES} retries",
236
+ documents_indexed: body["documents_indexed"]
237
+ )
238
+ end
239
+
240
+ wait = [body["retry_after"].to_f, config.search_retry_floor.to_f].max
241
+ log(:info) { "search index not ready for #{guild_id}; retrying in #{wait}s" }
242
+ sleep(wait)
243
+ end
244
+ end
245
+
246
+ # --- Guilds ------------------------------------------------------------
247
+
186
248
  # @return [Hash]
187
249
  def get_guild(guild_id)
188
250
  request(:get, "/guilds/#{guild_id}", route: "GET /guilds/#{guild_id}")
@@ -340,6 +402,40 @@ module DiscordStore
340
402
  rand * ceiling
341
403
  end
342
404
 
405
+ def search_request(guild_id, params, route)
406
+ request(:get, "/guilds/#{guild_id}/messages/search",
407
+ query: flatten_query(params), route: route)
408
+ rescue AuthError => e
409
+ # A 403 here is almost always the privileged intent rather than the
410
+ # token, and the generic message sends people to check credentials that
411
+ # are fine.
412
+ raise MissingIntentError if e.status == 403
413
+
414
+ raise
415
+ end
416
+
417
+ def index_pending?(body)
418
+ body.is_a?(Hash) && body["code"] == INDEX_NOT_READY_CODE
419
+ end
420
+
421
+ def normalise_search(body)
422
+ # messages is an array of arrays: it used to carry the surrounding
423
+ # context of each hit, and Discord kept the shape after dropping the
424
+ # context.
425
+ hits = Array(body["messages"]).flatten.compact
426
+ {
427
+ "messages" => hits.select { |message| own_message?(message) },
428
+ "total_results" => body["total_results"].to_i,
429
+ "indexing" => body["doing_deep_historical_index"] ? true : false
430
+ }
431
+ end
432
+
433
+ # Discord takes repeated keys for array params (author_id=1&author_id=2),
434
+ # which is what URI.encode_www_form does with an array value anyway.
435
+ def flatten_query(params)
436
+ params.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
437
+ end
438
+
343
439
  def build_url(path, query)
344
440
  url = "#{config.api_base}#{path}"
345
441
  return url if query.nil? || query.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DiscordStore
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/discord_store.rb CHANGED
@@ -18,6 +18,9 @@ require_relative "discord_store/transport/rate_limiter"
18
18
  require_relative "discord_store/transport/rest"
19
19
  require_relative "discord_store/transport/fake"
20
20
 
21
+ # Depends on Transport::REST's constants at load time, so it comes after it.
22
+ require_relative "discord_store/search"
23
+
21
24
  # Uses Discord as a database.
22
25
  #
23
26
  # It works, in the sense that the data goes in and comes back out. It is also an
@@ -95,6 +98,17 @@ module DiscordStore
95
98
  @kv ||= KV.new(rest: @rest, config: @config)
96
99
  end
97
100
 
101
+ # Discord's message index.
102
+ #
103
+ # Not the read path -- see {Search} for why at length -- and needs the
104
+ # MESSAGE_CONTENT privileged intent. Exists for the queries that a channel
105
+ # scan answers badly, such as finding chunks whose manifest is gone.
106
+ #
107
+ # @return [Search]
108
+ def search
109
+ @search ||= Search.new(rest: @rest, config: @config)
110
+ end
111
+
98
112
  # @return [GuildLimits]
99
113
  def limits
100
114
  @limits ||= GuildLimits.new(rest: @rest, config: @config)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: discord_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chayut
@@ -56,6 +56,7 @@ files:
56
56
  - lib/discord_store/log/record.rb
57
57
  - lib/discord_store/railtie.rb
58
58
  - lib/discord_store/replay.rb
59
+ - lib/discord_store/search.rb
59
60
  - lib/discord_store/snowflake.rb
60
61
  - lib/discord_store/tasks.rake
61
62
  - lib/discord_store/transport/bucket.rb