diff --git a/README.md b/README.md index ea5f141..cff12b8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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.0 +dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v0.1.1 ``` ## Usage @@ -38,9 +38,13 @@ await hooks.send_any(channel, strategy="round_robin", content="hi") # send via `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. +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 diff --git a/pyproject.toml b/pyproject.toml index b69f04b..c6a932d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dpy_webhooks" -version = "0.1.0" +version = "0.1.1" 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 = [ diff --git a/src/dpy_webhooks/__init__.py b/src/dpy_webhooks/__init__.py index 0d766f4..99f2ede 100644 --- a/src/dpy_webhooks/__init__.py +++ b/src/dpy_webhooks/__init__.py @@ -12,7 +12,7 @@ from .dpy_webhooks import ( WebhookStore, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "DPYWebhooks", diff --git a/src/dpy_webhooks/dpy_webhooks.py b/src/dpy_webhooks/dpy_webhooks.py index a02e018..f93383c 100644 --- a/src/dpy_webhooks/dpy_webhooks.py +++ b/src/dpy_webhooks/dpy_webhooks.py @@ -42,9 +42,15 @@ beyond the one-managed-webhook path, the lib can work with ALL of a channel's ex 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. +webhook — falling back to :meth:`get_or_create` when the channel has none. round-robin keeps +a per-channel in-process index that wraps on the live count and rebuilds if the count +changed. + +``pick`` only considers webhooks with a token — channel-follower and application-owned +webhooks have none, and ``Webhook.send`` cannot use them; they are skipped rather than +selected. ``send_any``'s self-heal deletes the specific dead webhook it picked (not the +managed one) unless the pick and the managed webhook are the same, in which case it goes +through :meth:`clear` as usual. """ from __future__ import annotations @@ -182,17 +188,18 @@ class DPYWebhooks: *, 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 + """select one EXISTING sendable 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""" + token-less webhooks (channel-follower / application-owned) are excluded since + ``Webhook.send`` cannot use them; ``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) + webhooks = sorted((w for w in await self.list(channel) if w.token is not None), key=lambda w: w.id) if not webhooks: return None @@ -210,8 +217,10 @@ class DPYWebhooks: **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)""" + has none; on a dead pick, deletes that dead webhook directly (clear() only when the + pick IS the managed webhook) then recreates + retries the send once""" webhook = await self.pick(channel, strategy=strategy) + picked_managed = webhook is None if webhook is None: webhook = await self.get_or_create(channel) try: @@ -221,10 +230,26 @@ class DPYWebhooks: raise log.warning("dpy_webhooks: picked webhook for channel %s appears dead, recreating", channel.id) - await self.clear(channel) + if not picked_managed: + managed = await self.get(channel) + picked_managed = managed is not None and managed.id == webhook.id + if picked_managed: + await self.clear(channel) + else: + await self._delete_dead(webhook) webhook = await self.get_or_create(channel) return await webhook.send(**kwargs) + async def _delete_dead(self, webhook: discord.Webhook) -> None: + """delete a webhook confirmed dead server-side; already-gone is not an error""" + try: + await webhook.delete() + except discord.NotFound: + pass + except discord.HTTPException as exc: + if not _is_dead_webhook(exc): + raise + 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""" @@ -270,7 +295,7 @@ class DPYWebhooks: async def count(self, channel: discord.TextChannel) -> int: """number of webhooks currently on the channel""" - return len(await channel.webhooks()) + return len(await self.list(channel)) 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 @@ -294,7 +319,7 @@ class DPYWebhooks: 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() + existing = await self.list(channel) if len(existing) >= MAX_WEBHOOKS_PER_CHANNEL: if not evict_oldest: raise WebhookCapacityError(channel.id)