add package: pyproject + src (DPYWebhooks, store protocol, cap + self-heal, fail-loud)

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 20:04:01 -04:00
parent 4518cbf30e
commit 74fd1bc4a5
4 changed files with 375 additions and 0 deletions
+19
View File
@@ -27,6 +27,21 @@ With a persistence store so webhooks survive a restart:
hooks = DPYWebhooks(bot, store=my_store) hooks = DPYWebhooks(bot, store=my_store)
``` ```
Work with **all** of a channel's existing webhooks (read live, no store, no create) — for
discovery, load-spreading, or rotation:
```python
await hooks.list(channel) # every live webhook on the channel
await hooks.pick(channel, strategy="round_robin") # select one existing (None if none)
await hooks.send_any(channel, strategy="round_robin", content="hi") # send via a picked one
```
`pick` strategies are `"first"` (oldest by id), `"random"`, and `"round_robin"` (per-channel
in-process index that wraps on the live count and rebuilds if the count changed); an unknown
strategy raises `ValueError`. `send_any` sends through a picked existing webhook, **falling
back to `get_or_create`** when the channel has none, and reuses the same dead-webhook
self-heal. None of these three create or touch the store — they read live from the channel.
## What you inject ## What you inject
- **`client`** — your `discord.Client` / `commands.Bot`. Used to rebuild persisted webhooks - **`client`** — your `discord.Client` / `commands.Bot`. Used to rebuild persisted webhooks
@@ -57,6 +72,10 @@ The module docstring (`help(dpy_webhooks)` / IDE hover) is the source of truth.
self-heal self-heal
- `clear(channel) -> None` — delete server-side + store record (idempotent) - `clear(channel) -> None` — delete server-side + store record (idempotent)
- `count(channel) -> int` — webhooks currently on the channel - `count(channel) -> int` — webhooks currently on the channel
- `list(channel) -> list[discord.Webhook]` — all live webhooks on the channel (no create)
- `pick(channel, *, strategy="first") -> discord.Webhook | None` — select an existing one
- `send_any(channel, *, strategy="round_robin", **kwargs) -> discord.WebhookMessage` — send
via a picked existing webhook, falling back to `get_or_create`
**Self-heal.** If `send` hits a dead webhook (deleted server-side / invalid token), the lib **Self-heal.** If `send` hits a dead webhook (deleted server-side / invalid token), the lib
clears the record, recreates the webhook, and retries the send **once**; a second failure clears the record, recreates the webhook, and retries the send **once**; a second failure
+15
View File
@@ -0,0 +1,15 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dpy_webhooks"
version = "0.1.0"
description = "Per-channel Discord webhook management for discord.py — get-or-create, persist, enforce the cap, send. Config-free, injectable, installable."
requires-python = ">=3.10"
dependencies = [
"discord.py>=2.2",
]
[tool.hatch.build.targets.wheel]
packages = ["src/dpy_webhooks"]
+26
View File
@@ -0,0 +1,26 @@
"""dpy_webhooks — per-channel discord webhook management (get-or-create, persist, cap, send)
see :mod:`dpy_webhooks.dpy_webhooks` for the full module docstring, api, and error contract
"""
from .dpy_webhooks import (
MAX_WEBHOOKS_PER_CHANNEL,
PICK_STRATEGIES,
DPYWebhooks,
DPYWebhooksError,
InMemoryWebhookStore,
WebhookCapacityError,
WebhookStore,
)
__version__ = "0.1.0"
__all__ = [
"DPYWebhooks",
"DPYWebhooksError",
"WebhookCapacityError",
"WebhookStore",
"InMemoryWebhookStore",
"MAX_WEBHOOKS_PER_CHANNEL",
"PICK_STRATEGIES",
"__version__",
]
+315
View File
@@ -0,0 +1,315 @@
"""per-channel discord webhook management: get-or-create one webhook per channel, persist
its {id, token} so it survives restarts, enforce discord's per-channel cap, and send
mental model
------------
creating a webhook per send is wasteful and burns rate limits, and discord caps webhooks at
10 per channel. this lib keeps ONE webhook per channel, reused across sends, persisted by
``{webhook_id, token}`` so a restart rebuilds it via ``Webhook.partial`` instead of creating
a fresh one (which would leak toward the cap). a dead cached webhook (deleted server-side) is
cleared and recreated transparently.
usage::
hooks = DPYWebhooks(bot, store=my_store) # store optional (in-memory if none)
await hooks.send(channel, content="hi", embeds=[e]) # get-or-create + send
wh = await hooks.get_or_create(channel) # reuse the webhook directly
store protocol (pluggable persistence)
--------------------------------------
persistence goes through the minimal async :class:`WebhookStore` protocol — the lib hardcodes
no backend. the default :class:`InMemoryWebhookStore` is lost on restart; inject a file- or
mongo-backed store (anything matching the protocol) for durability. a stored record is
``{"webhook_id": str, "token": str, "guild_id": str}``.
injection / config contract
---------------------------
inject the discord client and an optional store; nothing is read from a global config. the
client is used to rebuild partial webhooks (``Webhook.partial(id, token, client=...)``) so
discord.py supplies its own internal session and state — this needs ``discord.py>=2.2``.
error contract (fail-loud)
--------------------------
nothing is swallowed to ``None``. a full channel (10 webhooks) with ``evict_oldest`` unset
raises :class:`WebhookCapacityError`. missing Manage Webhooks perms propagate discord's own
``Forbidden`` unwrapped. a dead cached webhook is cleared + recreated; only a failed
recreation raises. ``send`` self-heals a dead webhook exactly once (clear + recreate + retry),
then raises loud. store failures propagate — the store owns its own durability.
discover / select / rotate
--------------------------
beyond the one-managed-webhook path, the lib can work with ALL of a channel's existing
webhooks, read live (no store, no create): :meth:`DPYWebhooks.list` returns them,
:meth:`DPYWebhooks.pick` selects one by strategy (``first`` / ``random`` / ``round_robin``,
``None`` if the channel has none), and :meth:`DPYWebhooks.send_any` sends through a picked
webhook — falling back to :meth:`get_or_create` when the channel has none, and reusing the
same dead-webhook self-heal. round-robin keeps a per-channel in-process index that wraps on
the live count and rebuilds if the count changed.
"""
from __future__ import annotations
import logging
import random
from typing import Any, Optional, Protocol, runtime_checkable
import discord
log = logging.getLogger(__name__)
MAX_WEBHOOKS_PER_CHANNEL = 10
_DEAD_WEBHOOK_STATUSES = (401, 404)
_UNKNOWN_WEBHOOK_CODE = 10015
_INVALID_TOKEN_CODE = 50027
PICK_STRATEGIES = ("first", "random", "round_robin")
class DPYWebhooksError(Exception):
"""base error for dpy_webhooks-specific faults"""
class WebhookCapacityError(DPYWebhooksError):
"""raised when a channel already holds MAX_WEBHOOKS_PER_CHANNEL webhooks and
evict_oldest was not set"""
def __init__(self, channel_id: int) -> None:
self.channel_id = channel_id
super().__init__(
f"channel {channel_id} already has {MAX_WEBHOOKS_PER_CHANNEL} webhooks "
f"(discord's cap); pass evict_oldest=True to reclaim the oldest"
)
@runtime_checkable
class WebhookStore(Protocol):
"""async persistence protocol for webhook records; any backend matching this works"""
async def get(self, channel_id: int) -> "Optional[dict]":
"""return {"webhook_id": str, "token": str, "guild_id": str} or None"""
...
async def set(self, channel_id: int, record: dict) -> None:
"""persist the record for a channel"""
...
async def delete(self, channel_id: int) -> None:
"""drop the record for a channel (idempotent)"""
...
class InMemoryWebhookStore:
"""default non-durable store; records live for the process only"""
def __init__(self) -> None:
self._data: dict[int, dict] = {}
async def get(self, channel_id: int) -> "Optional[dict]":
"""return the stored record or None"""
return self._data.get(channel_id)
async def set(self, channel_id: int, record: dict) -> None:
"""store the record"""
self._data[channel_id] = record
async def delete(self, channel_id: int) -> None:
"""drop the record if present"""
self._data.pop(channel_id, None)
def _is_dead_webhook(exc: discord.HTTPException) -> bool:
"""true when an HTTPException signals a webhook that no longer exists or has an invalid
token (the recover-and-recreate trigger)"""
if isinstance(exc, discord.NotFound):
return True
if exc.status in _DEAD_WEBHOOK_STATUSES:
return True
return exc.code in (_UNKNOWN_WEBHOOK_CODE, _INVALID_TOKEN_CODE)
class DPYWebhooks:
"""manage one reusable, persisted webhook per channel"""
def __init__(
self,
client: discord.Client,
*,
store: "Optional[WebhookStore]" = None,
default_name: str = "dpy_webhooks",
) -> None:
"""inject the client and an optional persistence store; in-memory if none"""
self._client = client
self._store: WebhookStore = store if store is not None else InMemoryWebhookStore()
self.default_name = default_name
self._cache: dict[int, discord.Webhook] = {}
self._rr: dict[int, dict] = {}
async def get_or_create(
self,
channel: discord.TextChannel,
*,
evict_oldest: bool = False,
) -> discord.Webhook:
"""return the channel's managed webhook, restoring from store or creating if absent;
raises WebhookCapacityError at the cap unless evict_oldest, and propagates Forbidden
when Manage Webhooks is missing"""
cached = self._cache.get(channel.id)
if cached is not None:
return cached
restored = await self._restore_from_store(channel)
if restored is not None:
return restored
return await self._create(channel, evict_oldest=evict_oldest)
async def get(self, channel: discord.TextChannel) -> "Optional[discord.Webhook]":
"""return the managed webhook if one exists (in-process cache then store), else None;
never creates"""
cached = self._cache.get(channel.id)
if cached is not None:
return cached
return await self._restore_from_store(channel)
async def list(self, channel: discord.TextChannel) -> "list[discord.Webhook]":
"""return ALL live webhooks on the channel, read straight from discord (no store, no
create); propagates Forbidden when Manage Webhooks is missing"""
return await channel.webhooks()
async def pick(
self,
channel: discord.TextChannel,
*,
strategy: str = "first",
) -> "Optional[discord.Webhook]":
"""select one EXISTING webhook on the channel by strategy without creating; returns
None if the channel has none, raises ValueError on an unknown strategy, and propagates
Forbidden when Manage Webhooks is missing
``first`` picks the oldest (lowest snowflake id), ``random`` picks uniformly, and
``round_robin`` advances a per-channel in-process index that wraps on the live count
and rebuilds if the count changed"""
if strategy not in PICK_STRATEGIES:
raise ValueError(f"unknown pick strategy '{strategy}'; expected one of {PICK_STRATEGIES}")
webhooks = sorted(await self.list(channel), key=lambda w: w.id)
if not webhooks:
return None
if strategy == "first":
return webhooks[0]
if strategy == "random":
return random.choice(webhooks)
return self._round_robin(channel.id, webhooks)
async def send_any(
self,
channel: discord.TextChannel,
*,
strategy: str = "round_robin",
**kwargs: Any,
) -> discord.WebhookMessage:
"""send via a picked EXISTING webhook, falling back to get_or_create when the channel
has none; reuses the dead-webhook self-heal (clear + recreate + retry once)"""
webhook = await self.pick(channel, strategy=strategy)
if webhook is None:
webhook = await self.get_or_create(channel)
try:
return await webhook.send(**kwargs)
except discord.HTTPException as exc:
if not _is_dead_webhook(exc):
raise
log.warning("dpy_webhooks: picked webhook for channel %s appears dead, recreating", channel.id)
await self.clear(channel)
webhook = await self.get_or_create(channel)
return await webhook.send(**kwargs)
def _round_robin(self, channel_id: int, webhooks: "list[discord.Webhook]") -> discord.Webhook:
"""advance and return the next webhook for a channel; the index wraps on the live
count and rebuilds when the count changed since last call"""
state = self._rr.get(channel_id)
if state is None or state["count"] != len(webhooks):
state = {"count": len(webhooks), "index": 0}
webhook = webhooks[state["index"] % len(webhooks)]
state["index"] = (state["index"] + 1) % len(webhooks)
self._rr[channel_id] = state
return webhook
async def send(self, channel: discord.TextChannel, **kwargs: Any) -> discord.WebhookMessage:
"""get_or_create then send; on a dead-webhook failure, clear + recreate + retry once,
then raise"""
webhook = await self.get_or_create(channel)
try:
return await webhook.send(**kwargs)
except discord.HTTPException as exc:
if not _is_dead_webhook(exc):
raise
log.warning("dpy_webhooks: webhook for channel %s appears dead, recreating", channel.id)
await self.clear(channel)
webhook = await self.get_or_create(channel)
return await webhook.send(**kwargs)
async def clear(self, channel: discord.TextChannel) -> None:
"""delete the managed webhook server-side (if known) and drop its store + cache
entries; already-gone is not an error"""
webhook = self._cache.pop(channel.id, None)
if webhook is None:
webhook = await self._restore_from_store(channel)
self._cache.pop(channel.id, None)
if webhook is not None:
try:
await webhook.delete()
except discord.NotFound:
pass
except discord.HTTPException as exc:
if not _is_dead_webhook(exc):
raise
await self._store.delete(channel.id)
async def count(self, channel: discord.TextChannel) -> int:
"""number of webhooks currently on the channel"""
return len(await channel.webhooks())
async def _restore_from_store(self, channel: discord.TextChannel) -> "Optional[discord.Webhook]":
"""rebuild a partial webhook from the stored record; on a rebuild fault, treat the
record as stale (clear + return None so the caller can recreate)"""
record = await self._store.get(channel.id)
if not record:
return None
try:
webhook = discord.Webhook.partial(
int(record["webhook_id"]),
record["token"],
client=self._client,
)
except (KeyError, ValueError, TypeError) as exc:
log.warning("dpy_webhooks: stale record for channel %s (%s), clearing", channel.id, exc)
await self._store.delete(channel.id)
self._cache.pop(channel.id, None)
return None
self._cache[channel.id] = webhook
return webhook
async def _create(self, channel: discord.TextChannel, *, evict_oldest: bool) -> discord.Webhook:
"""enforce the per-channel cap then create + persist a new webhook"""
existing = await channel.webhooks()
if len(existing) >= MAX_WEBHOOKS_PER_CHANNEL:
if not evict_oldest:
raise WebhookCapacityError(channel.id)
oldest = min(existing, key=lambda w: w.id)
log.warning("dpy_webhooks: channel %s at cap, evicting oldest webhook %s", channel.id, oldest.id)
await oldest.delete()
webhook = await channel.create_webhook(name=self.default_name)
await self._store.set(
channel.id,
{
"webhook_id": str(webhook.id),
"token": webhook.token,
"guild_id": str(channel.guild.id),
},
)
self._cache[channel.id] = webhook
return webhook