# dpy_webhooks Per-channel Discord webhook management for discord.py: get-or-create one webhook per channel, persist its `{id, token}` so it survives restarts, enforce Discord's 10-per-channel cap, and send through it. One webhook per channel, reused across sends — instead of creating a new one each time (wasteful, rate-limited, and it leaks toward the cap). ## Install ``` dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v0.1.2 ``` ## Usage ```python from dpy_webhooks import DPYWebhooks hooks = DPYWebhooks(bot) # store optional; in-memory if omitted await hooks.send(channel, content="hi", embeds=[e]) # get-or-create + send wh = await hooks.get_or_create(channel) # reuse the webhook directly ``` `send`/`send_any` forward `wait` straight to discord.py's `Webhook.send`, which defaults `wait=False` and returns `None` in that case; pass `wait=True` to get back the sent `discord.WebhookMessage` (e.g. to read `.id` or edit it later). With a persistence store so webhooks survive a restart: ```python 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`. `pick` only considers webhooks that have a token — channel- follower and application-owned webhooks have none and can't be sent through, so they're skipped rather than picked. `send_any` sends through a picked existing webhook, **falling back to `get_or_create`** when the channel has none. On a dead pick, self-heal deletes that specific dead webhook (not the managed one) and recreates + retries once — unless the pick IS the managed webhook, in which case it goes through the normal `clear()` path. 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 via `Webhook.partial(id, token, client=...)`, so discord.py supplies its own session and state. Requires **`discord.py>=2.2`**. - **`store`** (optional) — any object matching the `WebhookStore` protocol. Omit it and the lib uses a non-durable in-memory store (records lost on restart). ### The store protocol ```python class WebhookStore(Protocol): async def get(self, channel_id: int) -> dict | None: ... # {"webhook_id", "token", "guild_id"} or None async def set(self, channel_id: int, record: dict) -> None: ... async def delete(self, channel_id: int) -> None: ... ``` A file- or mongo-backed store satisfies this — the lib depends only on the protocol, not on any concrete store class. `InMemoryWebhookStore` is the bundled default. ## API & contract The module docstring (`help(dpy_webhooks)` / IDE hover) is the source of truth. In short: - `get_or_create(channel, *, evict_oldest=False) -> discord.Webhook` - `get(channel) -> discord.Webhook | None` — cache then store; never creates - `send(channel, **kwargs) -> discord.WebhookMessage | None` — get-or-create then send, with self-heal; `None` unless `wait=True` - `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 | None` — send via a picked existing webhook, falling back to `get_or_create`; `None` unless `wait=True` **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 raises loud. If the retried send carries `file=`/`files=`, the lib rebuilds a fresh `discord.File` for each one before retrying — discord.py closes every File's handle after the first send, so resending the same object would upload 0 bytes (or raise, for a path-backed File). A File that can't be safely rebuilt (a non-seekable, already-exhausted in-memory buffer with no backing path) raises `ValueError` rather than silently sending an empty attachment. **Fail-loud.** Nothing is swallowed to `None`. A channel already at 10 webhooks raises `WebhookCapacityError` (pass `evict_oldest=True` to reclaim the oldest instead). Missing Manage Webhooks perms propagate discord's own `Forbidden` unwrapped. Store failures propagate — the store owns its durability. ## Versioning Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.2` (not `discord.py-self`).