From 74fd1bc4a5d01a93bb112521db38ba080e6dd2bb Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 19:52:25 -0400 Subject: [PATCH] add package: pyproject + src (DPYWebhooks, store protocol, cap + self-heal, fail-loud) Signed-off-by: disqualifier --- README.md | 19 ++ pyproject.toml | 15 ++ src/dpy_webhooks/__init__.py | 26 +++ src/dpy_webhooks/dpy_webhooks.py | 315 +++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/dpy_webhooks/__init__.py create mode 100644 src/dpy_webhooks/dpy_webhooks.py diff --git a/README.md b/README.md index 9c4a993..ea5f141 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,21 @@ With a persistence store so webhooks survive a restart: 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 - **`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 - `clear(channel) -> None` — delete server-side + store record (idempotent) - `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 clears the record, recreates the webhook, and retries the send **once**; a second failure diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b69f04b --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/dpy_webhooks/__init__.py b/src/dpy_webhooks/__init__.py new file mode 100644 index 0000000..0d766f4 --- /dev/null +++ b/src/dpy_webhooks/__init__.py @@ -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__", +] diff --git a/src/dpy_webhooks/dpy_webhooks.py b/src/dpy_webhooks/dpy_webhooks.py new file mode 100644 index 0000000..a02e018 --- /dev/null +++ b/src/dpy_webhooks/dpy_webhooks.py @@ -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