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:
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -12,7 +12,7 @@ from .dpy_webhooks import (
|
||||
WebhookStore,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
__all__ = [
|
||||
"DPYWebhooks",
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user