# 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.1 ``` ## 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 ``` `lookup()` accepts a message id as an `int` or as a digit-only `str` (the snowflake shape JSON/DBs store), e.g. `cache.lookup("123456789012345678")`. ## 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. A **list** input whose sources resolve to the same name (two paths with the same basename, two `discord.File`s with the same `.filename`, ...) raises `DPYCacheError` before any upload — a dict input can't collide since dict keys are already unique. A `discord.File` source is safe to reuse across multiple names/batches: `cache()` always builds a genuinely fresh `discord.File` per send (re-opening a path-backed source, or seeking + copying a caller-supplied stream) rather than reusing the object you passed in. An opaque stream that can't be safely re-read (already closed, not seekable) raises `DPYCacheError` instead of silently sending stale or empty data. ## 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, a duplicate filename in a list input to `cache()`, `cache_url` non-200, a `lookup` jump-URL pointing at a different channel than the injected one, a `discord.File` source that can't be safely rebuilt for a repeat send). 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`). ### v0.1.1 - `cache()` now rejects a duplicate filename in a **list** input with `DPYCacheError` before any upload, instead of silently uploading both files and losing the first `FileRef` at merge time. - `_to_file` now builds a genuinely fresh `discord.File` per send for `discord.File` sources (previously returned the same object, which could swap filename/URL when one `File` was reused under two names, or re-send 0 bytes / raise `ValueError` on a spent stream). - `lookup()` now accepts a digit-only message-id string in addition to `int`. - Dropped the redundant `discord.Forbidden` member from an `except` tuple (it already subclasses `discord.HTTPException`); no behavior change.