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
|
## 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
|
## 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
|
`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
|
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
|
strategy raises `ValueError`. `pick` only considers webhooks that have a token — channel-
|
||||||
back to `get_or_create`** when the channel has none, and reuses the same dead-webhook
|
follower and application-owned webhooks have none and can't be sent through, so they're
|
||||||
self-heal. None of these three create or touch the store — they read live from the channel.
|
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
|
## What you inject
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "dpy_webhooks"
|
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."
|
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"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from .dpy_webhooks import (
|
|||||||
WebhookStore,
|
WebhookStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.1"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DPYWebhooks",
|
"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,
|
webhooks, read live (no store, no create): :meth:`DPYWebhooks.list` returns them,
|
||||||
:meth:`DPYWebhooks.pick` selects one by strategy (``first`` / ``random`` / ``round_robin``,
|
: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
|
``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
|
webhook — falling back to :meth:`get_or_create` when the channel has none. round-robin keeps
|
||||||
same dead-webhook self-heal. round-robin keeps a per-channel in-process index that wraps on
|
a per-channel in-process index that wraps on the live count and rebuilds if the count
|
||||||
the live count and rebuilds if the count changed.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -182,17 +188,18 @@ class DPYWebhooks:
|
|||||||
*,
|
*,
|
||||||
strategy: str = "first",
|
strategy: str = "first",
|
||||||
) -> "Optional[discord.Webhook]":
|
) -> "Optional[discord.Webhook]":
|
||||||
"""select one EXISTING webhook on the channel by strategy without creating; returns
|
"""select one EXISTING sendable webhook on the channel by strategy without creating;
|
||||||
None if the channel has none, raises ValueError on an unknown strategy, and propagates
|
returns None if the channel has none, raises ValueError on an unknown strategy, and
|
||||||
Forbidden when Manage Webhooks is missing
|
propagates Forbidden when Manage Webhooks is missing
|
||||||
|
|
||||||
``first`` picks the oldest (lowest snowflake id), ``random`` picks uniformly, and
|
token-less webhooks (channel-follower / application-owned) are excluded since
|
||||||
``round_robin`` advances a per-channel in-process index that wraps on the live count
|
``Webhook.send`` cannot use them; ``first`` picks the oldest (lowest snowflake id),
|
||||||
and rebuilds if the count changed"""
|
``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:
|
if strategy not in PICK_STRATEGIES:
|
||||||
raise ValueError(f"unknown pick strategy '{strategy}'; expected one of {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:
|
if not webhooks:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -210,8 +217,10 @@ class DPYWebhooks:
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> discord.WebhookMessage:
|
) -> discord.WebhookMessage:
|
||||||
"""send via a picked EXISTING webhook, falling back to get_or_create when the channel
|
"""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)
|
webhook = await self.pick(channel, strategy=strategy)
|
||||||
|
picked_managed = webhook is None
|
||||||
if webhook is None:
|
if webhook is None:
|
||||||
webhook = await self.get_or_create(channel)
|
webhook = await self.get_or_create(channel)
|
||||||
try:
|
try:
|
||||||
@@ -221,10 +230,26 @@ class DPYWebhooks:
|
|||||||
raise
|
raise
|
||||||
log.warning("dpy_webhooks: picked webhook for channel %s appears dead, recreating", channel.id)
|
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)
|
await self.clear(channel)
|
||||||
|
else:
|
||||||
|
await self._delete_dead(webhook)
|
||||||
webhook = await self.get_or_create(channel)
|
webhook = await self.get_or_create(channel)
|
||||||
return await webhook.send(**kwargs)
|
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:
|
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
|
"""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"""
|
count and rebuilds when the count changed since last call"""
|
||||||
@@ -270,7 +295,7 @@ class DPYWebhooks:
|
|||||||
|
|
||||||
async def count(self, channel: discord.TextChannel) -> int:
|
async def count(self, channel: discord.TextChannel) -> int:
|
||||||
"""number of webhooks currently on the channel"""
|
"""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]":
|
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
|
"""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:
|
async def _create(self, channel: discord.TextChannel, *, evict_oldest: bool) -> discord.Webhook:
|
||||||
"""enforce the per-channel cap then create + persist a new 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 len(existing) >= MAX_WEBHOOKS_PER_CHANNEL:
|
||||||
if not evict_oldest:
|
if not evict_oldest:
|
||||||
raise WebhookCapacityError(channel.id)
|
raise WebhookCapacityError(channel.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user