12 Commits
Author SHA1 Message Date
dsql 371428bb6b chore: bump to 1.1.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql b2bbca0cec fix: drop log-and-raise on the storage-channel send failure
the HTTPException handler logged at ERROR then re-raised - raise XOR log. HTTPException
propagates unwrapped per the documented error contract, so it already carries the failure
to the caller; the error log double-reported it. drop the log, keep the raise. the
genuine swallow-path WARNING elsewhere in the file is unchanged.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:12:29 -04:00
dsql 105545a421 release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-09 18:53:15 -04:00
dsql 66b7037cc2 docs: correct cache() docstring exception types to match the code
the docstring listed an unsupported content type under DPYCacheError, but _normalize/
_prevalidate raise ValueError for it (DPYCacheError is only the duplicate-filename and
missing-path cases). a caller following the docstring with except DPYCacheError would let
the ValueError escape. group the raises correctly: ValueError = unnamed bytes / unsupported
type; DPYCacheError = duplicate filename / missing path / count mismatch. doc-only.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 21:02:02 -04:00
dsql 51a4f7a817 fix: pre-validate all cache() inputs before any upload so a bad late input orphans nothing
cache() built discord.File objects per batch inside the send loop, so an unsupported type
or a missing file path in the 11th+ item raised only after earlier batches were already
uploaded - orphaning those stored messages with no returned ref. A new _prevalidate pass
checks every source up front (supported type; a path source exists and is a file) WITHOUT
opening any File or reading bytes, so a locally-detectable bad input fails loud before the
first send. The File is still built per batch in the loop (fresh-File-per-send unchanged),
so no extra open FDs are held. Neg control: old code uploaded batch 1 then raised on the
bad path (sends=1, orphaned); new raises with sends=0.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 20:02:19 -04:00
dsql 96cf2e86ba fix: log a warning on duplicate attachment filenames in lookup() (dpycache-2)
lookup()'s {filename: FileRef} comprehension silently kept only the last
attachment when a foreign message carried duplicate filenames (Discord
permits this; cache()'s own upload path already rejects it loudly, so the
gap is only a foreign message passed to lookup()). Refs still resolve
correctly by attachment_id, so keep the last-wins map (non-breaking) but log
the collision instead of staying silent about it.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:18:52 -04:00
dsql d611797882 fix: wrap owner-reopen File rebuild in DPYCacheError (dpycache-1)
_fresh_file_from_source wrapped the stream-copy branch's failures in
DPYCacheError but called discord.File(source.fp.name, ...) in the
owner-reopen branch with no try - a path-backed File whose backing file was
deleted between caching calls raised a raw FileNotFoundError/PermissionError
where the module's fail-loud contract promises DPYCacheError for every
lib-domain fault. Wrap the reopen the same way the stream-copy branch
already is.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:18:14 -04:00
dsql 7a1ab625fa docs: widen FileContent to include bytearray (dpycache-3)
_normalize and _to_file both already accept bytearray at runtime
(isinstance((bytes, bytearray)), bytes(source) coercion in the dict path),
but the FileContent Union only listed bytes - a typed consumer passing a
bytearray gets spurious mypy arg-type/dict-item errors for input the code
genuinely handles. Widen the Union to match actual behavior.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:17:48 -04:00
dsql c670ab952e refactor: version fallback to 0.0.0+unknown (drop hardcoded literal)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 17:01:33 -04:00
dsql 5885839f06 fix: __version__ hardcoded to 0.1.0, drifted from pyproject 0.1.1
__init__.py:14 hardcoded "0.1.0" while pyproject.toml, the README
install pin, and the README changelog all said 0.1.1 - a consumer
gating on __version__ read one release behind the actual tag. Derive
__version__ from installed package metadata
(importlib.metadata.version("dpy_cache")) so the hardcoded literal
can no longer drift from the release tag; keep a fallback literal for
the not-installed/editable case, synced to the current pyproject
version.

Bump 0.1.1 -> 0.1.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:15:48 -04:00
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
dsql 2e93a0bcee add package: pyproject + src (DPYCache, cache/cache_one/cache_url/resolve/lookup, fail-loud)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 19:20:08 -04:00
4 changed files with 441 additions and 6 deletions
+40 -6
View File
@@ -9,7 +9,7 @@ bites you.
## Install ## Install
``` ```
dpy_cache @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_cache.git@v0.1.0 dpy_cache @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_cache.git@v1.0.0
``` ```
## Usage ## Usage
@@ -46,6 +46,9 @@ refs = await cache.lookup(result.message_url) # jump url, message id, or disco
refs["avatar.png"].url 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 ## What you inject
A resolved Discord channel object (`discord.abc.Messageable` that supports `.send` and A resolved Discord channel object (`discord.abc.Messageable` that supports `.send` and
@@ -58,7 +61,16 @@ first use.
`cache_one` / `cache` accept, per file: `bytes`, a filesystem path (`str` or `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 `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. 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 ## Why `resolve()` exists
@@ -80,12 +92,34 @@ API. In short:
- `lookup(message) -> dict[str, FileRef]` - `lookup(message) -> dict[str, FileRef]`
**Fail-loud.** Nothing is swallowed. Lib-specific invariants raise `DPYCacheError` **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 (attachment-count mismatch after a send, a duplicate filename in a list input to `cache()`,
at a different channel than the injected one). Unnamed `bytes` in a list raise `ValueError`. `cache_url` non-200, a `lookup` jump-URL pointing at a different channel than the injected
Raw Discord errors (`Forbidden` / `HTTPException` / `NotFound`) propagate **unwrapped** so one, a `discord.File` source that can't be safely rebuilt for a repeat send). Unnamed
you can still branch on Discord's own types. `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 ## Versioning
Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.0` (not Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.0` (not
`discord.py-self`). `discord.py-self`).
### v0.1.2
- `__version__` is now derived from installed package metadata
(`importlib.metadata.version("dpy_cache")`) instead of a hardcoded string, so it can
no longer drift from the `pyproject.toml` version that the release tag is cut from. A
not-installed/editable checkout falls back to a literal that is kept in sync with the
current `pyproject.toml` version at each release.
### 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.
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dpy_cache"
version = "1.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"]
+29
View File
@@ -0,0 +1,29 @@
"""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 importlib.metadata import PackageNotFoundError, version
from .dpy_cache import (
MAX_ATTACHMENTS_PER_MESSAGE,
CacheResult,
DPYCache,
DPYCacheError,
FileContent,
FileRef,
)
try:
__version__ = version("dpy_cache")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__all__ = [
"DPYCache",
"DPYCacheError",
"FileRef",
"CacheResult",
"FileContent",
"MAX_ATTACHMENTS_PER_MESSAGE",
"__version__",
]
+356
View File
@@ -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