init: persist images/files to a discord storage channel with durable, re-resolvable references

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 19:20:04 -04:00
commit e06bf12fae
2 changed files with 106 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# claude
.claude/
# python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.eggs/
# env
.venv/
venv/
.env
+91
View File
@@ -0,0 +1,91 @@
# dpy_cache
Persist images/files to Discord by uploading them to a storage channel, and get back
durable references — a message jump URL plus per-file CDN URLs keyed by their original
filename. Discord attachments live on the CDN permanently; this lib wraps "stash it, hand
me a URL" and also returns a re-resolvable reference so the ~24h URL-signature expiry never
bites you.
## Install
```
dpy_cache @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_cache.git@v0.1.0
```
## Usage
The headline is dead-simple single-file caching:
```python
from dpy_cache import DPYCache
cache = DPYCache(bot.get_channel(STORAGE_CHANNEL_ID))
url = (await cache.cache_one("img.png", image_bytes)).url # raw bytes -> url
url = (await cache.cache_url("img.png", "https://...")).url # remote url -> url
```
Several files at once (batches automatically over 10 per message):
```python
result = await cache.cache({
"avatar.png": avatar_bytes,
"banner.png": "/tmp/banner.png",
})
result.files["avatar.png"].url # cdn url now
result.message_url # jump link to the stored message
# later, if the url may have expired:
fresh = await cache.resolve(result.files["avatar.png"])
```
Reverse lookup a stored message back into refs:
```python
refs = await cache.lookup(result.message_url) # jump url, message id, or discord.Message
refs["avatar.png"].url
```
## What you inject
A resolved Discord channel object (`discord.abc.Messageable` that supports `.send` and
`.fetch_message` — a TextChannel or Thread). The lib holds **no client** and reads **no
global config**; it calls those two methods on the channel directly. The bot must already
have access to the channel — an inaccessible or bad channel raises Discord's own error on
first use.
### `FileContent` inputs
`cache_one` / `cache` accept, per file: `bytes`, a filesystem path (`str` or
`os.PathLike`), or a `discord.File`. In a **list** input, raw `bytes` have no filename and
raise `ValueError` — pass a dict `{name: bytes}` or a `discord.File` to name bytes.
## Why `resolve()` exists
Since late 2023, Discord attachment CDN URLs are signed and the signature expires (~24h).
The attachment itself is permanent; only the URL goes stale (a raw stored URL 403s after
expiry). `FileRef` carries the message/channel/attachment ids, and `resolve(ref)` re-fetches
the message and returns a freshly-signed URL. Use the URL immediately and you can ignore
`resolve()`; persist a reference and you call `resolve()` when you read it back.
## API & contract
The module docstring (`help(dpy_cache)` / IDE hover) is the source of truth for the full
API. In short:
- `cache(files, *, content=None) -> CacheResult`
- `cache_one(name, data, *, content=None) -> FileRef`
- `cache_url(name, url, *, content=None) -> FileRef`
- `resolve(ref) -> str`
- `lookup(message) -> dict[str, FileRef]`
**Fail-loud.** Nothing is swallowed. Lib-specific invariants raise `DPYCacheError`
(attachment-count mismatch after a send, `cache_url` non-200, a `lookup` jump-URL pointing
at a different channel than the injected one). Unnamed `bytes` in a list raise `ValueError`.
Raw Discord errors (`Forbidden` / `HTTPException` / `NotFound`) propagate **unwrapped** so
you can still branch on Discord's own types.
## Versioning
Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.0` (not
`discord.py-self`).