add package: pyproject + src (DPYWebhooks, store protocol, cap + self-heal, fail-loud)
Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
1 parent
4518cbf30e
commit
74fd1bc4a5
4 files changed
+375
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user