From 23b1ae64edf95ece83a5b162fcea64e1bea50344 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 25 Jun 2026 14:50:05 -0400 Subject: [PATCH] add package: pyproject + src (core Webhook, WebhookResult, errors, discord layer) Signed-off-by: disqualifier --- pyproject.toml | 20 ++++ src/aiowebhooks/__init__.py | 24 ++++ src/aiowebhooks/discord.py | 88 +++++++++++++++ src/aiowebhooks/errors.py | 16 +++ src/aiowebhooks/result.py | 30 +++++ src/aiowebhooks/sender.py | 214 ++++++++++++++++++++++++++++++++++++ 6 files changed, 392 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/aiowebhooks/__init__.py create mode 100644 src/aiowebhooks/discord.py create mode 100644 src/aiowebhooks/errors.py create mode 100644 src/aiowebhooks/result.py create mode 100644 src/aiowebhooks/sender.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34a6d7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "aiowebhooks" +version = "0.1.0" +description = "async webhook sender (aiohttp) with round-robin urls, retry, and proxy rotation; optional discord.py embeds" +requires-python = ">=3.10" +dependencies = [ + "aiohttp>=3.9", +] + +[project.optional-dependencies] +discord = [ + "discord.py>=2.3", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/aiowebhooks"] diff --git a/src/aiowebhooks/__init__.py b/src/aiowebhooks/__init__.py new file mode 100644 index 0000000..785ce02 --- /dev/null +++ b/src/aiowebhooks/__init__.py @@ -0,0 +1,24 @@ +"""aiowebhooks — async webhook sender (aiohttp), optional discord.py embeds. + +post a json payload to a webhook url (or a round-robin pool) with 429/5xx retry and +optional proxy rotation; every send returns a WebhookResult and never raises on a +send failure. the [discord] extra adds DiscordWebhook (username/avatar + Embed +handling) layered over the same core. + + from aiowebhooks import Webhook + + wh = Webhook("https://example.com/hook") + result = await wh.send({"content": "hello"}) + if not result.ok: + ... + +DiscordWebhook lives in aiowebhooks.discord and needs the [discord] extra. +""" + +from .errors import NoUrlsError, WebhookError +from .result import WebhookResult +from .sender import Webhook + +__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"] + +__version__ = "0.1.0" diff --git a/src/aiowebhooks/discord.py b/src/aiowebhooks/discord.py new file mode 100644 index 0000000..b64c0fa --- /dev/null +++ b/src/aiowebhooks/discord.py @@ -0,0 +1,88 @@ +"""discord.py conveniences over the core sender ([discord] extra). + +`DiscordWebhook` wraps a core `Webhook`, adds discord identity (username/avatar, +overridable per send) and `Embed` handling, builds the discord webhook json, and +delegates the POST to the core — inheriting rotation / proxy / retry / result. +importing this module without discord.py installed is fine; constructing or sending +raises a clear RuntimeError naming the extra. +""" + +import logging +from typing import List, Optional, Union + +from .result import WebhookResult +from .sender import Webhook + +try: + import discord + _HAVE_DISCORD = True +except ImportError: + _HAVE_DISCORD = False + +log = logging.getLogger(__name__) + +_MISSING = "discord support requires aiowebhooks[discord]" + + +class DiscordWebhook: + """discord webhook sender — builds payloads, delegates sending to a core Webhook""" + + def __init__( + self, + urls, + *, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + session=None, + proxies: object = None, + **core_kwargs, + ): + if not _HAVE_DISCORD: + raise RuntimeError(_MISSING) + self.username = username + self.avatar_url = avatar_url + self._webhook = Webhook(urls, session=session, proxies=proxies, **core_kwargs) + + @staticmethod + def _embed_to_dict(embed) -> dict: + """normalize a discord.Embed or a raw dict to a payload dict""" + if isinstance(embed, dict): + return embed + if hasattr(embed, "to_dict"): + return embed.to_dict() + raise TypeError("embeds must be discord.Embed objects or dicts") + + def _build( + self, + content: Optional[str], + embeds: Optional[List[Union[dict, "discord.Embed"]]], + username: Optional[str], + avatar_url: Optional[str], + ) -> dict: + """assemble the discord webhook json from identity + content + embeds""" + payload: dict = {} + name = username if username is not None else self.username + avatar = avatar_url if avatar_url is not None else self.avatar_url + if name is not None: + payload["username"] = name + if avatar is not None: + payload["avatar_url"] = avatar + if content is not None: + payload["content"] = content + if embeds: + payload["embeds"] = [self._embed_to_dict(e) for e in embeds] + return payload + + async def send( + self, + content: Optional[str] = None, + *, + embeds: Optional[List[Union[dict, "discord.Embed"]]] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> WebhookResult: + """build the discord payload and send it through the core webhook""" + if not _HAVE_DISCORD: + raise RuntimeError(_MISSING) + payload = self._build(content, embeds, username, avatar_url) + return await self._webhook.send(payload) diff --git a/src/aiowebhooks/errors.py b/src/aiowebhooks/errors.py new file mode 100644 index 0000000..5acb3f4 --- /dev/null +++ b/src/aiowebhooks/errors.py @@ -0,0 +1,16 @@ +"""exception types for aiowebhooks. + +these are surfaced for callers that want to branch on a specific failure cause. +note the core `Webhook.send` does NOT raise on a send failure — it returns a +`WebhookResult` with `ok=False` and the cause captured in `error`. these types +exist for the few raise paths (bad construction, missing extra) and as a base for +any future raising surface. +""" + + +class WebhookError(Exception): + """base for aiowebhooks errors""" + + +class NoUrlsError(WebhookError): + """raised when a Webhook is constructed with no urls""" diff --git a/src/aiowebhooks/result.py b/src/aiowebhooks/result.py new file mode 100644 index 0000000..92cca75 --- /dev/null +++ b/src/aiowebhooks/result.py @@ -0,0 +1,30 @@ +"""the result object every send returns. + +`Webhook.send` never raises on a send failure; it always returns a `WebhookResult`. +callers branch on `result.ok`. success and every failure mode (4xx/5xx, timeout, +exhausted proxies) populate the same shape so call sites stay uniform. +""" + +from dataclasses import dataclass +from typing import Dict, Optional, Union + + +@dataclass +class WebhookResult: + """outcome of a webhook send attempt + + `ok` is the single branch point. `status` is the final HTTP status (None if the + request never completed, e.g. a timeout). `url` is the pool url that was used. + `attempts` counts total tries across retries/proxy rotations. `error` is a short + cause string on failure (None on success). `response` is the response payload + (parsed json if possible, else text, None if none). `proxy` is the canonical + proxy string used for the final attempt, or None if no proxy provider. + """ + + ok: bool + status: Optional[int] = None + url: str = "" + attempts: int = 0 + error: Optional[str] = None + response: Optional[Union[str, Dict]] = None + proxy: Optional[str] = None diff --git a/src/aiowebhooks/sender.py b/src/aiowebhooks/sender.py new file mode 100644 index 0000000..53899d4 --- /dev/null +++ b/src/aiowebhooks/sender.py @@ -0,0 +1,214 @@ +"""core async webhook sender (aiohttp only, no discord knowledge). + +`Webhook` posts a JSON dict to a url (or round-robins a pool), handling 429/5xx +retries and optional proxy rotation, and always returns a `WebhookResult` — it +never raises on a send failure. the discord layer builds payloads and delegates +the actual POST here so it inherits rotation / proxy / retry / result. +""" + +import asyncio +import logging +import time +from typing import Dict, List, Optional, Union +from urllib.parse import unquote, urlsplit + +import aiohttp + +from .errors import NoUrlsError +from .result import WebhookResult + +log = logging.getLogger(__name__) + + +def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]: + """canonical host:port:user:pass (or host:port) from an aiohttp proxies dict + + duck-typed: reads whatever the provider's get() returned without importing it. + returns None if the dict is empty or unparseable. + """ + if not proxies_dict: + return None + url = proxies_dict.get("http") or proxies_dict.get("https") + if not url: + return None + try: + parts = urlsplit(url) + host = parts.hostname + if host is None: + return None + host = host.lower() + port = str(parts.port) if parts.port is not None else "" + if parts.username: + user = unquote(parts.username) + password = unquote(parts.password) if parts.password is not None else "" + return f"{host}:{port}:{user}:{password}" + return f"{host}:{port}" + except ValueError: + return None + + +class Webhook: + """generic async webhook sender over a round-robin url pool""" + + def __init__( + self, + urls: Union[str, List[str]], + *, + session: Optional[aiohttp.ClientSession] = None, + proxies: object = None, + timeout: float = 15, + max_retries: int = 3, + max_proxy_retries: int = 3, + clock=time.monotonic, + ): + self._urls = [urls] if isinstance(urls, str) else list(urls) + if not self._urls: + raise NoUrlsError("Webhook requires at least one url") + self._session = session + self._proxies = proxies + self.timeout = timeout + self.max_retries = max_retries + self.max_proxy_retries = max_proxy_retries + self._clock = clock + self._index = 0 + + def _next_url(self) -> str: + """return the next url in round-robin order""" + url = self._urls[self._index] + self._index = (self._index + 1) % len(self._urls) + return url + + @staticmethod + def _retry_after(status: int, headers, body) -> Optional[float]: + """seconds to wait on a 429, from body retry_after then Retry-After header""" + if status != 429: + return None + if isinstance(body, dict) and body.get("retry_after") is not None: + try: + return float(body["retry_after"]) + except (TypeError, ValueError): + pass + header = headers.get("Retry-After") if headers else None + if header is not None: + try: + return float(header) + except (TypeError, ValueError): + pass + return None + + async def send(self, payload: Dict) -> WebhookResult: + """post a json payload to the next pool url; always returns a result + + handles 429 (wait + retry, capped by max_retries), 5xx (retry, capped), and + timeout/connection errors with optional proxy rotation (burn + next, capped + by max_proxy_retries). 4xx other than 429 fail immediately. never raises on a + send failure. + """ + url = self._next_url() + session = self._session + owns_session = session is None + if owns_session: + session = aiohttp.ClientSession() + try: + return await self._send_loop(session, url, payload) + finally: + if owns_session: + await session.close() + + async def _send_loop( + self, session: aiohttp.ClientSession, url: str, payload: Dict + ) -> WebhookResult: + """retry/rotation loop for a single send""" + attempts = 0 + retries = 0 + proxy_tries = 0 + last_proxy: Optional[str] = None + timeout = aiohttp.ClientTimeout(total=self.timeout) + + while True: + proxy_url = None + if self._proxies is not None: + try: + proxy_dict = self._proxies.get() + except Exception as error: + if type(error).__name__ == "ProxiesExhaustedError": + return WebhookResult( + ok=False, status=None, url=url, attempts=attempts, + error="proxies exhausted", proxy=last_proxy, + ) + raise + last_proxy = _proxy_string(proxy_dict) + proxy_url = (proxy_dict or {}).get("http") or (proxy_dict or {}).get("https") + + attempts += 1 + try: + async with session.post( + url, json=payload, proxy=proxy_url, timeout=timeout + ) as resp: + status = resp.status + body = await self._read_body(resp) + + if 200 <= status < 300: + return WebhookResult( + ok=True, status=status, url=url, attempts=attempts, + response=body, proxy=last_proxy, + ) + + wait = self._retry_after(status, resp.headers, body) + if wait is not None and retries < self.max_retries: + retries += 1 + log.warning("webhook 429 on %s; waiting %.3fs (retry %d/%d)", + url, wait, retries, self.max_retries) + await asyncio.sleep(wait) + continue + + if status >= 500 and retries < self.max_retries: + retries += 1 + log.warning("webhook %d on %s; retry %d/%d", + status, url, retries, self.max_retries) + continue + + return WebhookResult( + ok=False, status=status, url=url, attempts=attempts, + error=f"http {status}", response=body, proxy=last_proxy, + ) + + except (aiohttp.ClientError, asyncio.TimeoutError) as error: + if self._proxies is not None and proxy_tries < self.max_proxy_retries: + if self._burn(last_proxy): + proxy_tries += 1 + continue + return WebhookResult( + ok=False, status=None, url=url, attempts=attempts, + error="proxies exhausted", proxy=last_proxy, + ) + return WebhookResult( + ok=False, status=None, url=url, attempts=attempts, + error=f"{type(error).__name__}: {error}", proxy=last_proxy, + ) + + def _burn(self, proxy: Optional[str]) -> bool: + """burn the current proxy; return False if the provider is exhausted + + catches a provider ProxiesExhaustedError duck-typed by class name (the + provider is never imported), so a dying pool ends the loop cleanly. + """ + try: + self._proxies.burn(proxy) + return True + except Exception as error: + if type(error).__name__ == "ProxiesExhaustedError": + return False + raise + + @staticmethod + async def _read_body(resp) -> Optional[Union[str, Dict]]: + """response payload as json if parseable, else text, else None""" + try: + return await resp.json(content_type=None) + except Exception: + try: + text = await resp.text() + except Exception: + return None + return text or None