dsql 248d6ebb6b fix: reject duplicate-name uploads and rebuild fresh Files per send
cache() silently lost a FileRef when a list input had two sources
resolving to the same name (two paths with the same basename, or two
discord.File objects with the same .filename): both files were
uploaded (real cost) but the merge step kept only the last, orphaning
the first. _normalize now rejects a duplicate name with DPYCacheError
before any upload happens.

_to_file returned the same discord.File object for a discord.File
source, only mutating .filename. Reusing one File under two keys
swapped filename/URL between refs; reusing a BytesIO-backed File
re-sent 0 bytes; reusing a path-backed File after discord.py's own
close() raised ValueError. _to_file now builds a genuinely distinct
discord.File per send, re-opening a path-backed source or seeking and
copying a caller-supplied stream; an unrebuildable opaque stream now
raises DPYCacheError instead of corrupting or crashing a later send.

Also: lookup() accepts a digit-only message-id string (previously only
bare int fetched directly, a numeric string routed into the jump-url
parser and raised); dropped a redundant discord.Forbidden member from
an except tuple (Forbidden already subclasses HTTPException); tightened
_parse_jump_url's return type off a dead Optional and added `from err`
to its raise.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 15:32:28 -04:00

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:

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):

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:

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.Files 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.
S
Description
Discord-channel asset cacher for discord.py
Readme
92 KiB
Languages
Python 100%