fix: skip token-less webhooks in pick(); self-heal deletes the dead pick, not the managed webhook

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>
This commit is contained in:
2026-07-03 15:31:06 -04:00
parent 74fd1bc4a5
commit ce48f2aea8
4 changed files with 49 additions and 20 deletions
+8 -4
View File
@@ -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
+1 -1
View File
@@ -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 = [
+1 -1
View File
@@ -12,7 +12,7 @@ from .dpy_webhooks import (
WebhookStore,
)
__version__ = "0.1.0"
__version__ = "0.1.1"
__all__ = [
"DPYWebhooks",
+39 -14
View File
@@ -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)