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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +76 -3
- data/lib/discord_store/blob_store.rb +59 -4
- data/lib/discord_store/configuration.rb +6 -1
- data/lib/discord_store/errors.rb +43 -0
- data/lib/discord_store/search.rb +220 -0
- data/lib/discord_store/tasks.rake +35 -0
- data/lib/discord_store/transport/fake.rb +169 -4
- data/lib/discord_store/transport/rest.rb +101 -5
- data/lib/discord_store/version.rb +1 -1
- data/lib/discord_store.rb +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f10dcdd64b9bd9de7e97d25e7d8281cc44a0810cb7a1925a638a89bfbba20fd6
|
|
4
|
+
data.tar.gz: 68b9779ba8f103aea3d34393fe82f6dadb745c6bda5e93a47d9231be9d5d39b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
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
|
data/lib/discord_store/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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?
|
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.
|
|
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
|