Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing

This commit is contained in:
Jack Kingsman
2026-03-05 22:27:24 -08:00
parent 7534f0cc54
commit cb4333df4f
13 changed files with 840 additions and 54 deletions
+2 -2
View File
@@ -88,8 +88,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
class AppriseModule(FanoutModule):
"""Sends push notifications via Apprise for incoming messages."""
def __init__(self, config_id: str, config: dict) -> None:
super().__init__(config_id, config)
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._last_error: str | None = None
async def on_message(self, data: dict) -> None:
+2 -1
View File
@@ -12,9 +12,10 @@ class FanoutModule:
Subclasses must override the ``status`` property.
"""
def __init__(self, config_id: str, config: dict) -> None:
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
self.config_id = config_id
self.config = config
self.name = name
async def start(self) -> None:
"""Start the module (e.g. connect to broker). Override for persistent connections."""
+3 -4
View File
@@ -20,8 +20,7 @@ class BotModule(FanoutModule):
"""
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
super().__init__(config_id, config)
self._name = name
super().__init__(config_id, config, name=name)
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
@@ -110,10 +109,10 @@ class BotModule(FanoutModule):
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("Bot '%s' execution timed out", self._name)
logger.warning("Bot '%s' execution timed out", self.name)
return
except Exception as e:
logger.warning("Bot '%s' execution error: %s", self._name, e)
logger.warning("Bot '%s' execution error: %s", self.name, e)
return
if response:
+1 -4
View File
@@ -108,10 +108,7 @@ class FanoutManager:
return
try:
if config_type == "bot":
module = cls(config_id, config_blob, name=cfg.get("name", "Bot"))
else:
module = cls(config_id, config_blob)
module = cls(config_id, config_blob, name=cfg.get("name", ""))
await module.start()
self._modules[config_id] = (module, scope)
logger.info(
+2 -2
View File
@@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
class MqttCommunityModule(FanoutModule):
"""Wraps a CommunityMqttPublisher for community packet sharing."""
def __init__(self, config_id: str, config: dict) -> None:
super().__init__(config_id, config)
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
async def start(self) -> None:
+2 -2
View File
@@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
class MqttPrivateModule(FanoutModule):
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
def __init__(self, config_id: str, config: dict) -> None:
super().__init__(config_id, config)
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
async def start(self) -> None:
+15 -6
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import httpx
@@ -14,8 +16,8 @@ logger = logging.getLogger(__name__)
class WebhookModule(FanoutModule):
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
def __init__(self, config_id: str, config: dict) -> None:
super().__init__(config_id, config)
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client: httpx.AsyncClient | None = None
self._last_error: str | None = None
@@ -44,18 +46,25 @@ class WebhookModule(FanoutModule):
method = self.config.get("method", "POST").upper()
extra_headers = self.config.get("headers", {})
secret = self.config.get("secret", "")
hmac_secret = self.config.get("hmac_secret", "")
hmac_header = self.config.get("hmac_header", "X-Webhook-Signature")
headers = {
"Content-Type": "application/json",
"X-Webhook-Event": event_type,
**extra_headers,
}
if secret:
headers["X-Webhook-Secret"] = secret
import json as _json
body_bytes = _json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
if hmac_secret:
sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}"
try:
resp = await self._client.request(method, url, json=data, headers=headers)
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
resp.raise_for_status()
self._last_error = None
except httpx.HTTPStatusError as exc:
+2 -2
View File
@@ -44,10 +44,10 @@ def _validate_mqtt_private_config(config: dict) -> None:
def _validate_mqtt_community_config(config: dict) -> None:
"""Validate mqtt_community config blob."""
iata = config.get("iata", "")
if iata and not _IATA_RE.fullmatch(iata.upper().strip()):
if not iata or not _IATA_RE.fullmatch(iata.upper().strip()):
raise HTTPException(
status_code=400,
detail="IATA code must be exactly 3 uppercase alphabetic characters",
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
)