commit e06bf12fae7995e5a4ac5e1fbe61e2635b432f64 Author: disqualifier Date: Thu Jul 2 19:20:04 2026 -0400 init: persist images/files to a discord storage channel with durable, re-resolvable references Signed-off-by: disqualifier diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be64430 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# claude +.claude/ + +# python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.eggs/ + +# env +.venv/ +venv/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c1c27e --- /dev/null +++ b/README.md @@ -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`).