Add webhooks & reformat a bit

This commit is contained in:
Jack Kingsman
2026-03-05 19:10:29 -08:00
parent 5ecb63fde9
commit e3e4e0b839
7 changed files with 1086 additions and 61 deletions
+24 -14
View File
@@ -20,10 +20,32 @@ def _register_module_types() -> None:
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
_MODULE_TYPES["webhook"] = WebhookModule
def _matches_filter(filter_value: Any, key: str) -> bool:
"""Check a single filter value (channels or contacts) against a key.
Supported shapes:
"all" -> True
"none" -> False
["key1", "key2"] -> key in list (only listed)
{"except": ["key1", "key2"]} -> key not in list (all except listed)
"""
if filter_value == "all":
return True
if filter_value == "none":
return False
if isinstance(filter_value, list):
return key in filter_value
if isinstance(filter_value, dict) and "except" in filter_value:
return key not in filter_value["except"]
return False
def _scope_matches_message(scope: dict, data: dict) -> bool:
@@ -37,21 +59,9 @@ def _scope_matches_message(scope: dict, data: dict) -> bool:
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "")
if msg_type == "CHAN":
channels = messages.get("channels", "none")
if channels == "all":
return True
if channels == "none":
return False
if isinstance(channels, list):
return conversation_key in channels
return _matches_filter(messages.get("channels", "none"), conversation_key)
elif msg_type == "PRIV":
contacts = messages.get("contacts", "none")
if contacts == "all":
return True
if contacts == "none":
return False
if isinstance(contacts, list):
return conversation_key in contacts
return _matches_filter(messages.get("contacts", "none"), conversation_key)
return False
+79
View File
@@ -0,0 +1,79 @@
"""Fanout module for webhook (HTTP POST) delivery."""
from __future__ import annotations
import logging
import httpx
from app.fanout.base import FanoutModule
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)
self._client: httpx.AsyncClient | None = None
self._last_error: str | None = None
async def start(self) -> None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
self._last_error = None
async def stop(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def on_message(self, data: dict) -> None:
await self._send(data, event_type="message")
async def on_raw(self, data: dict) -> None:
await self._send(data, event_type="raw_packet")
async def _send(self, data: dict, *, event_type: str) -> None:
if not self._client:
return
url = self.config.get("url", "")
if not url:
return
method = self.config.get("method", "POST").upper()
extra_headers = self.config.get("headers", {})
secret = self.config.get("secret", "")
headers = {
"Content-Type": "application/json",
"X-Webhook-Event": event_type,
**extra_headers,
}
if secret:
headers["X-Webhook-Secret"] = secret
try:
resp = await self._client.request(method, url, json=data, headers=headers)
resp.raise_for_status()
self._last_error = None
except httpx.HTTPStatusError as exc:
self._last_error = f"HTTP {exc.response.status_code}"
logger.warning(
"Webhook %s returned %s for %s",
self.config_id,
exc.response.status_code,
url,
)
except httpx.RequestError as exc:
self._last_error = str(exc)
logger.warning("Webhook %s request error: %s", self.config_id, exc)
@property
def status(self) -> str:
if not self.config.get("url"):
return "disconnected"
if self._last_error:
return "error"
return "connected"
+25 -1
View File
@@ -12,7 +12,7 @@ from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
@@ -65,12 +65,32 @@ def _validate_bot_config(config: dict) -> None:
) from None
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
url = config.get("url", "")
if not url:
raise HTTPException(status_code=400, detail="url is required for webhook")
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
method = config.get("method", "POST").upper()
if method not in ("POST", "PUT", "PATCH"):
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
headers = config.get("headers", {})
if not isinstance(headers, dict):
raise HTTPException(status_code=400, detail="headers must be a JSON object")
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
return {"messages": "all", "raw_packets": "none"}
if config_type == "webhook":
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
messages = "all"
return {"messages": messages, "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
@@ -108,6 +128,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_mqtt_community_config(body.config)
elif body.type == "bot":
_validate_bot_config(body.config)
elif body.type == "webhook":
_validate_webhook_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -156,6 +178,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_mqtt_community_config(config_to_validate)
elif existing["type"] == "bot":
_validate_bot_config(config_to_validate)
elif existing["type"] == "webhook":
_validate_webhook_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None: