diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2c13e48 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "dpy_cache" +version = "0.1.0" +description = "Persist images/files to a Discord storage channel and get durable, re-resolvable CDN references — config-free, injectable, installable." +requires-python = ">=3.10" +dependencies = [ + "discord.py>=2.0", + "aiohttp>=3.8", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/dpy_cache"] diff --git a/src/dpy_cache/__init__.py b/src/dpy_cache/__init__.py new file mode 100644 index 0000000..ac390cc --- /dev/null +++ b/src/dpy_cache/__init__.py @@ -0,0 +1,24 @@ +"""dpy_cache — persist images/files to discord and get durable, re-resolvable references + +see :mod:`dpy_cache.dpy_cache` for the full module docstring, api, and error contract +""" +from .dpy_cache import ( + MAX_ATTACHMENTS_PER_MESSAGE, + CacheResult, + DPYCache, + DPYCacheError, + FileContent, + FileRef, +) + +__version__ = "0.1.0" + +__all__ = [ + "DPYCache", + "DPYCacheError", + "FileRef", + "CacheResult", + "FileContent", + "MAX_ATTACHMENTS_PER_MESSAGE", + "__version__", +] diff --git a/src/dpy_cache/dpy_cache.py b/src/dpy_cache/dpy_cache.py new file mode 100644 index 0000000..b8009fe --- /dev/null +++ b/src/dpy_cache/dpy_cache.py @@ -0,0 +1,264 @@ +"""persist images/files to discord by uploading to a storage channel, returning durable +references (message jump url + per-file cdn urls keyed by original filename) + +mental model +------------ +a discord attachment url is effectively permanent storage: upload a file to a channel once +and the attachment lives on discord's cdn forever. this lib wraps "upload to a storage +channel, hand me back the urls" so a bot can stash content and reference it later by its +original filename. + +caveat that shapes the api: since late 2023 discord attachment cdn urls are SIGNED and the +signature EXPIRES (~24h). the attachment itself is permanent; only the url's ``ex``/``is``/ +``hm`` params go stale, so a raw stored url 403s after expiry. this lib also returns a +re-resolvable ``FileRef`` and a one-call :meth:`DPYCache.resolve` that re-fetches the +message and hands back a freshly-signed url. + +usage (the headline is dead-simple single-file caching):: + + 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/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(s) to the stored message(s) + + fresh = await cache.resolve(result.files["avatar.png"]) # later, if the url may be stale + +injection / config contract +--------------------------- +inject the resolved discord channel object; the lib holds no client and reads no global +config. it calls ``.send`` / ``.fetch_message`` on the channel directly, so any +``discord.abc.Messageable`` that supports both (TextChannel, Thread) works. the bot must +already have access to the channel; an inaccessible or bad channel raises discord's own +error on first use. + +error contract (fail-loud) +-------------------------- +nothing is swallowed. lib-specific invariants raise :class:`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 input raises ``ValueError``. raw +discord errors (``Forbidden`` / ``HTTPException`` / ``NotFound``) propagate UNWRAPPED so +callers can still branch on discord's types. +""" +from __future__ import annotations + +import io +import logging +import os +from dataclasses import dataclass +from typing import Union + +import aiohttp +import discord + +log = logging.getLogger(__name__) + +MAX_ATTACHMENTS_PER_MESSAGE = 10 + +FileContent = Union[bytes, str, "os.PathLike[str]", discord.File] + + +class DPYCacheError(Exception): + """raised for dpy_cache's own invariants (count mismatch, remote fetch failure, + channel mismatch); raw discord errors propagate unwrapped instead""" + + +@dataclass +class FileRef: + """a durable reference to one cached attachment; ``url`` may expire (~24h), re-fetch a + fresh one via :meth:`DPYCache.resolve`""" + + filename: str + url: str + message_id: int + channel_id: int + attachment_id: int + + +@dataclass +class CacheResult: + """result of a :meth:`DPYCache.cache` call; ``message_url`` is a single jump url for one + batch or a list of jump urls when the input spanned more than one message""" + + message_url: Union[str, list[str]] + files: dict[str, FileRef] + + +def _basename(path: "str | os.PathLike[str]") -> str: + """filename portion of a path-like, as the map key for list inputs""" + return os.path.basename(os.fspath(path)) + + +def _ref_from_attachment(name: str, attachment: discord.Attachment, message: discord.Message) -> FileRef: + """build a FileRef from a sent attachment and its message""" + return FileRef( + filename=name, + url=attachment.url, + message_id=message.id, + channel_id=message.channel.id, + attachment_id=attachment.id, + ) + + +class DPYCache: + """upload files to an injected storage channel and hand back durable references""" + + def __init__(self, channel: discord.abc.Messageable) -> None: + """store the injected storage channel; caller resolves it, lib holds no client""" + self._channel = channel + + async def cache( + self, + files: "dict[str, FileContent] | list[FileContent]", + *, + content: "str | None" = None, + ) -> CacheResult: + """upload files to the storage channel (batching over 10/message) and return refs + keyed by original filename; raises on unnamed bytes, count mismatch, or a discord + send error""" + items = self._normalize(files) + merged: dict[str, FileRef] = {} + jump_urls: list[str] = [] + + for start in range(0, len(items), MAX_ATTACHMENTS_PER_MESSAGE): + group = items[start:start + MAX_ATTACHMENTS_PER_MESSAGE] + discord_files = [self._to_file(name, source) for name, source in group] + try: + message = await self._channel.send(content=content, files=discord_files) + except (discord.Forbidden, discord.HTTPException): + log.error("dpy_cache: send of %d file(s) to the storage channel failed", len(group)) + raise + + attachments = list(message.attachments) + if len(attachments) != len(group): + sent_names = [name for name, _ in group] + got = len(attachments) + missing = sent_names[got:] if got < len(sent_names) else sent_names + raise DPYCacheError( + f"discord returned {got} attachment(s) for {len(group)} sent file(s); " + f"dropped: {', '.join(missing)}" + ) + + for (name, _), attachment in zip(group, attachments): + merged[name] = _ref_from_attachment(name, attachment, message) + jump_urls.append(message.jump_url) + + message_url: "str | list[str]" = jump_urls[0] if len(jump_urls) == 1 else jump_urls + return CacheResult(message_url=message_url, files=merged) + + async def cache_one( + self, + name: str, + data: FileContent, + *, + content: "str | None" = None, + ) -> FileRef: + """cache a single file, return its ref""" + result = await self.cache({name: data}, content=content) + return result.files[name] + + async def cache_url( + self, + name: str, + url: str, + *, + content: "str | None" = None, + ) -> FileRef: + """fetch a remote url then cache its bytes as ``name``; raises DPYCacheError on + non-200""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status != 200: + raise DPYCacheError(f"cache_url: GET {url} returned status {response.status}") + data = await response.read() + return await self.cache_one(name, data, content=content) + + async def resolve(self, ref: FileRef) -> str: + """re-fetch the message and return a fresh (unexpired) cdn url for the attachment; + raises if the message or attachment is gone (never a stale url) + + a gone MESSAGE propagates discord's own ``NotFound`` from ``fetch_message``; a gone + ATTACHMENT on a still-live message raises :class:`DPYCacheError` (the lib's own domain + fault) rather than a fabricated discord internal error""" + message = await self._channel.fetch_message(ref.message_id) + for attachment in message.attachments: + if attachment.id == ref.attachment_id: + return attachment.url + raise DPYCacheError( + f"attachment {ref.attachment_id} ({ref.filename}) no longer on message {ref.message_id}" + ) + + async def lookup(self, message: "str | int | discord.Message") -> dict[str, FileRef]: + """reverse lookup: a jump-url, message id, or Message -> {filename: FileRef}; raises + (NotFound) if the message is gone, never an empty map for a missing message""" + resolved = await self._resolve_message(message) + return { + attachment.filename: _ref_from_attachment(attachment.filename, attachment, resolved) + for attachment in resolved.attachments + } + + def _normalize(self, files: "dict[str, FileContent] | list[FileContent]") -> "list[tuple[str, FileContent]]": + """normalize dict/list input to an ordered list of (name, source); raises ValueError + on unnamed bytes in a list""" + if isinstance(files, dict): + return list(files.items()) + + items: "list[tuple[str, FileContent]]" = [] + for source in files: + if isinstance(source, discord.File): + items.append((source.filename, source)) + elif isinstance(source, (str, os.PathLike)): + items.append((_basename(source), source)) + elif isinstance(source, (bytes, bytearray)): + raise ValueError( + "raw bytes in a list input have no filename; pass a dict {name: bytes} " + "or a discord.File for named bytes" + ) + else: + raise ValueError(f"unsupported file content type in list: {type(source).__name__}") + return items + + def _to_file(self, name: str, source: FileContent) -> discord.File: + """build a fresh discord.File for ``source`` named ``name``; a fresh File is built + per send so the source survives multi-batch and retries""" + if isinstance(source, discord.File): + source.filename = name + return source + if isinstance(source, (bytes, bytearray)): + return discord.File(io.BytesIO(bytes(source)), filename=name) + if isinstance(source, (str, os.PathLike)): + return discord.File(source, filename=name) + raise ValueError(f"unsupported file content type: {type(source).__name__}") + + async def _resolve_message(self, message: "str | int | discord.Message") -> discord.Message: + """turn a jump-url / id / Message into a fetched Message on the injected channel""" + if isinstance(message, discord.Message): + return message + if isinstance(message, int): + return await self._channel.fetch_message(message) + if isinstance(message, str): + channel_id, message_id = self._parse_jump_url(message) + if channel_id is not None and channel_id != self._channel.id: + raise DPYCacheError( + f"lookup jump-url targets channel {channel_id} but the injected channel is " + f"{self._channel.id}; this lib holds no client and can only fetch its own channel" + ) + return await self._channel.fetch_message(message_id) + raise ValueError(f"unsupported lookup input type: {type(message).__name__}") + + @staticmethod + def _parse_jump_url(url: str) -> "tuple[int | None, int]": + """parse (channel_id, message_id) out of a discord jump url; raises DPYCacheError on + a malformed url""" + parts = url.rstrip("/").split("/") + try: + message_id = int(parts[-1]) + channel_id = int(parts[-2]) + except (IndexError, ValueError): + raise DPYCacheError(f"lookup: not a valid discord jump url: {url}") + return channel_id, message_id