fix: fail loud on missing emoji dir, stem collisions, and corrupt images

refresh() previously treated a missing assets/emojis folder (wrong cwd,
missing WorkingDirectory=/WORKDIR) as an empty one, silently deleting
every application emoji on the app every restart with no signal and no
way back (recreated emojis get new IDs). _scan_folder now raises
DPYAppEmojisError naming the absent path instead; a present-but-empty
folder still mirrors as before.

Two files sharing a name stem across extensions (check.png + check.gif)
used to silently collapse to one synced emoji via last-write-wins,
breaking the documented delete+re-add replace workflow with no error.
_scan_folder now raises DPYAppEmojisError naming both colliding files.

A valid extension with unrecognized magic bytes (truncated download,
mismatched format) made discord.py raise an anonymous
ValueError('Unsupported image type given') from create_application_emoji,
which the lib's (Forbidden, HTTPException) catch didn't cover, leaving
no way to tell which file in a large folder was broken. _scan_folder now
pre-flights the same magic-byte check discord.py itself uses and raises
ValueError naming the offending file before any create is attempted.

Also collapses the redundant Forbidden member in the create/delete except
clauses (Forbidden already subclasses HTTPException); log-then-raise
behavior is unchanged.

Bump 0.1.0 -> 0.1.1.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-03 15:32:16 -04:00
parent 7d878845df
commit d59bdcc9b9
4 changed files with 88 additions and 21 deletions
+22 -5
View File
@@ -34,6 +34,12 @@ appmojis.list() # ["success_mark", ...]
Call `refresh()` at startup, or from a command, to re-sync on demand — the lib adds no
commands of its own.
A **missing** `assets/emojis` folder is not the same as an **empty** one: if the folder
doesn't exist (wrong cwd, missing `WorkingDirectory=`/`WORKDIR`), `refresh()` raises
`DPYAppEmojisError` naming the path instead of mirroring an empty desired set — which would
otherwise delete every application emoji on the next restart. An empty-but-present folder is
still honored as "delete everything."
## Dot-access vs `emoji()`
`self.get` is a namespace whose attribute lookup returns the emoji by name. It lives apart
@@ -60,11 +66,22 @@ targets **`discord.py>=2.5`**.
## Contract (fail-loud)
The module docstring (`help(dpy_appemojis)` / IDE hover) is the source of truth. Nothing is
swallowed: an invalid emoji name from a bad filename or an oversized file raises `ValueError`
naming the file; exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create; a
discord API error (`Forbidden` / `HTTPException`) propagates **unwrapped** so a partial sync
never hides behind a silent success. `emoji(name)` / `get.<name>` for a name that wasn't
synced raise `KeyError` / `AttributeError` (call `refresh()` first).
swallowed:
- an invalid emoji name from a bad filename, an oversized file, or a file whose bytes aren't
a recognized PNG/JPEG/GIF/WEBP image (corrupt, truncated, or mismatched extension) raises
`ValueError` naming the file
- two files sharing a name stem across extensions (e.g. `check.png` and `check.gif`) raise
`DPYAppEmojisError` naming **both** files, instead of silently syncing one and dropping the
other
- a missing `assets/emojis` folder raises `DPYAppEmojisError` naming the path (see above)
rather than mirroring an empty set
- exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create
- a discord API error (`HTTPException`, which covers `Forbidden`) propagates **unwrapped** so
a partial sync never hides behind a silent success
`emoji(name)` / `get.<name>` for a name that wasn't synced raise `KeyError` / `AttributeError`
(call `refresh()` first).
## Versioning
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "dpy_appemojis"
version = "0.1.0"
version = "0.1.1"
description = "Mirror a project folder onto the bot's application emojis for discord.py — folder is the source of truth. Config-free, injectable, installable."
requires-python = ">=3.10"
dependencies = [
+1 -1
View File
@@ -12,7 +12,7 @@ from .dpy_appemojis import (
RefreshResult,
)
__version__ = "0.1.0"
__version__ = "0.1.1"
__all__ = [
"DPYAppEmojis",
+64 -14
View File
@@ -27,6 +27,12 @@ the folder is always ``assets/emojis`` relative to the process cwd. accepted ext
``.png .jpg .jpeg .gif .webp``; max file size 256 KB; emoji names must be 232 chars of
``[A-Za-z0-9_]``; the application emoji cap is 2000.
a MISSING folder is not the same as an EMPTY one: an absent ``assets/emojis`` (wrong cwd,
missing ``WorkingDirectory=``/``WORKDIR``) raises :class:`DPYAppEmojisError` instead of
silently mirroring an empty desired set — the latter would delete every application emoji
on the next :meth:`refresh`. an empty-but-present folder is a legitimate "delete everything"
signal and is honored as before.
dot-access namespace
--------------------
``self.get`` is a small namespace object whose ``__getattr__`` returns the emoji by name
@@ -42,11 +48,16 @@ in discord.py 2.5), so this needs ``discord.py>=2.5``.
error contract (fail-loud)
--------------------------
nothing is swallowed. an invalid emoji name (bad filename) or an oversized file raises
``ValueError`` naming the file. exceeding the app emoji cap raises before any create. discord
API errors (``Forbidden`` / ``HTTPException``) propagate UNWRAPPED — a partial sync never
hides behind a silent success. :meth:`emoji` / ``get.<name>`` for an unsynced name raise
``KeyError`` / ``AttributeError``.
nothing is swallowed. an invalid emoji name (bad filename), an oversized file, or a file
whose bytes aren't a recognized image (corrupt/truncated/mismatched extension) raises
``ValueError`` naming the file. two files sharing a name stem across extensions (e.g.
``check.png`` and ``check.gif``) raise :class:`DPYAppEmojisError` naming both, instead of
silently syncing one and dropping the other. a missing ``assets/emojis`` folder raises
:class:`DPYAppEmojisError` naming the path (see above) rather than mirroring an empty set.
exceeding the app emoji cap raises before any create. discord API errors (``HTTPException``,
which covers ``Forbidden``) propagate UNWRAPPED — a partial sync never hides behind a silent
success. :meth:`emoji` / ``get.<name>`` for an unsynced name raise ``KeyError`` /
``AttributeError``.
"""
from __future__ import annotations
@@ -67,6 +78,20 @@ MAX_APP_EMOJIS = 2000
_NAME_RE = re.compile(r"^[A-Za-z0-9_]{2,32}$")
def _looks_like_image(header: bytes) -> bool:
"""mirror discord.py's own magic-byte sniff (png/jpeg/gif/webp) so a bad file is caught
here, with the offending path, instead of as an anonymous ValueError from create"""
if header.startswith(b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"):
return True
if header[0:3] == b"\xff\xd8\xff" or header[6:10] in (b"JFIF", b"Exif"):
return True
if header.startswith((b"\x47\x49\x46\x38\x37\x61", b"\x47\x49\x46\x38\x39\x61")):
return True
if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
return True
return False
class DPYAppEmojisError(Exception):
"""base error for dpy_appemojis-specific faults"""
@@ -107,8 +132,16 @@ class DPYAppEmojis:
async def refresh(self) -> RefreshResult:
"""sync app emojis to mirror assets/emojis: create new files, delete removed ones,
leave same-name emojis untouched; raises on invalid name, oversized file, cap
overflow, or any discord API error"""
leave same-name emojis untouched
Raises:
DPYAppEmojisError: EMOJI_DIR does not exist, two files share a name stem across
extensions, or the sync would exceed the application emoji cap
ValueError: a file has an invalid name, is oversized, or isn't a recognized
image format
discord.HTTPException: any discord API error (includes Forbidden), propagated
unwrapped
"""
existing = {emoji.name: emoji for emoji in await self._client.fetch_application_emojis()}
desired = self._scan_folder()
@@ -127,7 +160,7 @@ class DPYAppEmojis:
image = fh.read()
try:
emoji = await self._client.create_application_emoji(name=name, image=image)
except (discord.Forbidden, discord.HTTPException):
except discord.HTTPException:
log.error("dpy_appemojis: creating application emoji '%s' failed", name)
raise
current[name] = emoji
@@ -137,7 +170,7 @@ class DPYAppEmojis:
if name not in desired:
try:
await emoji.delete()
except (discord.Forbidden, discord.HTTPException):
except discord.HTTPException:
log.error("dpy_appemojis: deleting application emoji '%s' failed", name)
raise
current.pop(name, None)
@@ -166,12 +199,21 @@ class DPYAppEmojis:
return self._access
def _scan_folder(self) -> dict[str, str]:
"""scan EMOJI_DIR for valid image files -> {name: path}; raises ValueError on a bad
name or an oversized file"""
desired: dict[str, str] = {}
if not os.path.isdir(EMOJI_DIR):
return desired
"""scan EMOJI_DIR for valid image files -> {name: path}
Raises:
DPYAppEmojisError: EMOJI_DIR does not exist, or two files share a name stem
across extensions (e.g. ``check.png`` and ``check.gif``)
ValueError: a file has an invalid name, is oversized, or isn't a recognized
image format
"""
if not os.path.isdir(EMOJI_DIR):
raise DPYAppEmojisError(
f"EMOJI_DIR '{EMOJI_DIR}' does not exist (relative to cwd {os.getcwd()!r}); "
"refusing to mirror an empty desired set onto the application emojis"
)
desired: dict[str, str] = {}
for entry in sorted(os.listdir(EMOJI_DIR)):
path = os.path.join(EMOJI_DIR, entry)
if not os.path.isfile(path):
@@ -186,5 +228,13 @@ class DPYAppEmojis:
raise ValueError(
f"emoji file {path} is {size} bytes, over the {MAX_EMOJI_BYTES}-byte limit"
)
with open(path, "rb") as fh:
header = fh.read(16)
if not _looks_like_image(header):
raise ValueError(f"emoji file {path} is not a recognized PNG/JPEG/GIF/WEBP image")
if stem in desired:
raise DPYAppEmojisError(
f"duplicate emoji name '{stem}' from both {desired[stem]} and {path}"
)
desired[stem] = path
return desired