|
|
|
@@ -0,0 +1,356 @@
|
|
|
|
|
"""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, 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 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, bytearray, str, "os.PathLike[str]", discord.File]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DPYCacheError(Exception):
|
|
|
|
|
"""raised for dpy_cache's own invariants (count mismatch, duplicate filename, remote
|
|
|
|
|
fetch failure, channel mismatch, an unrebuildable discord.File source); 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 ValueError on unnamed bytes in a list input or an
|
|
|
|
|
unsupported content type, DPYCacheError on a duplicate filename in a list input or a
|
|
|
|
|
missing file path (all checked before any upload, so a locally-detectable bad input
|
|
|
|
|
in a >10-file batch costs zero requests and orphans nothing) or an attachment-count
|
|
|
|
|
mismatch after a send, and propagates discord's own Forbidden/HTTPException unwrapped
|
|
|
|
|
on a send failure"""
|
|
|
|
|
items = self._normalize(files)
|
|
|
|
|
self._prevalidate(items)
|
|
|
|
|
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.HTTPException:
|
|
|
|
|
# raise XOR log: HTTPException propagates unwrapped (the documented contract),
|
|
|
|
|
# so the exception carries the failure to the caller - no error log here.
|
|
|
|
|
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 (``int`` or a digit-only ``str``, matching
|
|
|
|
|
the snowflake shape stored in JSON/DBs), or Message -> {filename: FileRef}; raises
|
|
|
|
|
(NotFound) if the message is gone, never an empty map for a missing message
|
|
|
|
|
|
|
|
|
|
keyed by filename, so a foreign message with duplicate attachment filenames (Discord
|
|
|
|
|
allows this; ``cache()`` itself never produces one) collapses to the last attachment
|
|
|
|
|
of that name in the map - logged, not silent. every ref still resolves correctly by
|
|
|
|
|
its own ``attachment_id`` regardless"""
|
|
|
|
|
resolved = await self._resolve_message(message)
|
|
|
|
|
refs: dict[str, FileRef] = {}
|
|
|
|
|
for attachment in resolved.attachments:
|
|
|
|
|
if attachment.filename in refs:
|
|
|
|
|
log.warning(
|
|
|
|
|
"dpy_cache: message %s has more than one attachment named %r; "
|
|
|
|
|
"lookup() keeps only the last (attachment_id=%s)",
|
|
|
|
|
resolved.id, attachment.filename, attachment.id,
|
|
|
|
|
)
|
|
|
|
|
refs[attachment.filename] = _ref_from_attachment(attachment.filename, attachment, resolved)
|
|
|
|
|
return refs
|
|
|
|
|
|
|
|
|
|
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, or DPYCacheError on a duplicate name in a list (dict keys
|
|
|
|
|
can't collide by construction)"""
|
|
|
|
|
if isinstance(files, dict):
|
|
|
|
|
return list(files.items())
|
|
|
|
|
|
|
|
|
|
items: "list[tuple[str, FileContent]]" = []
|
|
|
|
|
seen: "set[str]" = set()
|
|
|
|
|
for source in files:
|
|
|
|
|
if isinstance(source, discord.File):
|
|
|
|
|
name = source.filename
|
|
|
|
|
elif isinstance(source, (str, os.PathLike)):
|
|
|
|
|
name = _basename(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__}")
|
|
|
|
|
if name in seen:
|
|
|
|
|
raise DPYCacheError(
|
|
|
|
|
f"duplicate filename {name!r} in list input; every list source must resolve "
|
|
|
|
|
f"to a unique name, or the later upload silently orphans the earlier one"
|
|
|
|
|
)
|
|
|
|
|
seen.add(name)
|
|
|
|
|
items.append((name, source))
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
def _prevalidate(self, items: "list[tuple[str, FileContent]]") -> None:
|
|
|
|
|
"""check every source is a supported, locally-resolvable input BEFORE any upload
|
|
|
|
|
|
|
|
|
|
catches an unsupported content type or a missing/directory file path up front so a
|
|
|
|
|
bad input in a >10-file batch fails loud before the first send rather than after
|
|
|
|
|
earlier batches already landed (which would orphan those stored messages). does NOT
|
|
|
|
|
open Files or read bytes - only a cheap type check and, for a path source, an
|
|
|
|
|
existence/is-file probe; the actual File is still built per batch in the send loop.
|
|
|
|
|
"""
|
|
|
|
|
for name, source in items:
|
|
|
|
|
if isinstance(source, (discord.File, bytes, bytearray)):
|
|
|
|
|
continue
|
|
|
|
|
if isinstance(source, (str, os.PathLike)):
|
|
|
|
|
path = os.fspath(source)
|
|
|
|
|
if not os.path.isfile(path):
|
|
|
|
|
raise DPYCacheError(
|
|
|
|
|
f"file source for {name!r} is not an existing file: {path!r}"
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
raise ValueError(f"unsupported file content type for {name!r}: {type(source).__name__}")
|
|
|
|
|
|
|
|
|
|
def _to_file(self, name: str, source: FileContent) -> discord.File:
|
|
|
|
|
"""build a genuinely fresh discord.File for ``source`` named ``name``, distinct from
|
|
|
|
|
any File previously handed to a send; a fresh File is built per send so the source
|
|
|
|
|
survives multi-batch, retries, and reuse of the same source under multiple names"""
|
|
|
|
|
if isinstance(source, discord.File):
|
|
|
|
|
return self._fresh_file_from_source(source, name)
|
|
|
|
|
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__}")
|
|
|
|
|
|
|
|
|
|
def _fresh_file_from_source(self, source: discord.File, name: str) -> discord.File:
|
|
|
|
|
"""rebuild a distinct discord.File from an existing one without mutating or
|
|
|
|
|
re-sending ``source`` itself; re-opens the path for an owner-opened File, or
|
|
|
|
|
seeks to the original position and copies the bytes for a caller-supplied stream;
|
|
|
|
|
raises DPYCacheError if the underlying stream can't be safely re-read"""
|
|
|
|
|
if source._owner and hasattr(source.fp, "name"):
|
|
|
|
|
try:
|
|
|
|
|
return discord.File(
|
|
|
|
|
source.fp.name,
|
|
|
|
|
filename=name,
|
|
|
|
|
spoiler=source.spoiler,
|
|
|
|
|
description=source.description,
|
|
|
|
|
)
|
|
|
|
|
except OSError as err:
|
|
|
|
|
raise DPYCacheError(
|
|
|
|
|
f"discord.File source for {name!r} could not be reopened from "
|
|
|
|
|
f"{source.fp.name!r} (backing file missing or unreadable)"
|
|
|
|
|
) from err
|
|
|
|
|
if source.fp.closed:
|
|
|
|
|
raise DPYCacheError(
|
|
|
|
|
f"discord.File source for {name!r} is already closed and can't be safely "
|
|
|
|
|
f"reused; pass the raw bytes or path instead of a spent discord.File"
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
source.fp.seek(source._original_pos)
|
|
|
|
|
data = source.fp.read()
|
|
|
|
|
source.fp.seek(source._original_pos)
|
|
|
|
|
except (OSError, ValueError) as err:
|
|
|
|
|
raise DPYCacheError(
|
|
|
|
|
f"discord.File source for {name!r} could not be re-read (opaque or "
|
|
|
|
|
f"already-consumed stream); pass the raw bytes instead of a discord.File"
|
|
|
|
|
) from err
|
|
|
|
|
return discord.File(
|
|
|
|
|
io.BytesIO(data),
|
|
|
|
|
filename=name,
|
|
|
|
|
spoiler=source.spoiler,
|
|
|
|
|
description=source.description,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _resolve_message(self, message: "str | int | discord.Message") -> discord.Message:
|
|
|
|
|
"""turn a jump-url / id / digit-only id string / 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):
|
|
|
|
|
if message.isdigit():
|
|
|
|
|
return await self._channel.fetch_message(int(message))
|
|
|
|
|
channel_id, message_id = self._parse_jump_url(message)
|
|
|
|
|
if 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, 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) as err:
|
|
|
|
|
raise DPYCacheError(f"lookup: not a valid discord jump url: {url}") from err
|
|
|
|
|
return channel_id, message_id
|