|
|
|
@@ -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
|