dpywebhooks-1 (HIGH): pick()/send_any could select token-less channel-follower (type 2) or application-owned (type 3) webhooks returned by channel.webhooks(). Webhook.send raises a raw ValueError on those before any HTTP call, and send_any only caught discord.HTTPException, so the ValueError bypassed fallback and self-heal entirely. strategy='first' picks the lowest id, so any channel following an announcement channel could be permanently broken. Fix: pick() now filters candidates to w.token is not None. dpywebhooks-2 (HIGH): send_any's self-heal always called clear(), which targets the managed webhook (cache/store), not the dead webhook that was actually picked. When the dead pick and the managed webhook differed, this deleted a healthy managed webhook and churned delete+create toward the 10-cap on every send. Fix: self-heal now deletes the dead picked webhook directly (bot-auth webhook.delete() authorizes on it without its own token); clear() is only used when the pick IS the managed webhook. dpywebhooks-8 (nit): count() and _create() now read live webhooks through list() instead of calling channel.webhooks() directly, so the token filter and any future list() change reach all three call sites from one source. Version 0.1.0 -> 0.1.1. README + module docstrings updated to document the token filter and the corrected self-heal delete target. Signed-off-by: disqualifier <dev@disqualifier.me>
97 lines
4.4 KiB
Markdown
97 lines
4.4 KiB
Markdown
# 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.1
|
|
```
|
|
|
|
## 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
|
|
```
|
|
|
|
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` — get-or-create then send, with
|
|
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
|
|
raises loud.
|
|
|
|
**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`).
|