Both were annotated -> discord.WebhookMessage and README-documented that way, but discord.py's Webhook.send defaults wait=False and returns None unless the caller passes wait=True - so the annotation overpromised and a consumer chaining .id off the result without knowing to pass wait=True would get an AttributeError. Widen both annotations to Optional and add a one-line note (module docstring + README) on the wait=True requirement. Signed-off-by: disqualifier <dev@disqualifier.me>
5.1 KiB
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
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:
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:
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— yourdiscord.Client/commands.Bot. Used to rebuild persisted webhooks viaWebhook.partial(id, token, client=...), so discord.py supplies its own session and state. Requiresdiscord.py>=2.2.store(optional) — any object matching theWebhookStoreprotocol. Omit it and the lib uses a non-durable in-memory store (records lost on restart).
The store protocol
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.Webhookget(channel) -> discord.Webhook | None— cache then store; never createssend(channel, **kwargs) -> discord.WebhookMessage | None— get-or-create then send, with self-heal;Noneunlesswait=Trueclear(channel) -> None— delete server-side + store record (idempotent)count(channel) -> int— webhooks currently on the channellist(channel) -> list[discord.Webhook]— all live webhooks on the channel (no create)pick(channel, *, strategy="first") -> discord.Webhook | None— select an existing onesend_any(channel, *, strategy="round_robin", **kwargs) -> discord.WebhookMessage | None— send via a picked existing webhook, falling back toget_or_create;Noneunlesswait=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).