add package: pyproject + src (core Webhook, WebhookResult, errors, discord layer)
Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
parent
3f164abf84
commit
7a0c8abf30
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@ -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"]
|
||||
24
src/aiowebhooks/__init__.py
Normal file
24
src/aiowebhooks/__init__.py
Normal file
@ -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"
|
||||
88
src/aiowebhooks/discord.py
Normal file
88
src/aiowebhooks/discord.py
Normal file
@ -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)
|
||||
16
src/aiowebhooks/errors.py
Normal file
16
src/aiowebhooks/errors.py
Normal file
@ -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"""
|
||||
30
src/aiowebhooks/result.py
Normal file
30
src/aiowebhooks/result.py
Normal file
@ -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
|
||||
214
src/aiowebhooks/sender.py
Normal file
214
src/aiowebhooks/sender.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user