""" Webhook management and dispatch for ObsiGate. Webhooks are HTTP POST callbacks triggered on file/directory events. Configuration is persisted in data/webhooks.json. Events: file_created, file_deleted, file_modified, file_renamed, directory_created, directory_deleted, directory_renamed """ import asyncio import hashlib import hmac import json import logging import uuid from datetime import datetime, timezone from pathlib import Path import aiohttp logger = logging.getLogger("obsigate.webhooks") WEBHOOKS_FILE = Path("data/webhooks.json") VALID_EVENTS = { "file_created", "file_deleted", "file_modified", "file_renamed", "directory_created", "directory_deleted", "directory_renamed", } def _read() -> list: if not WEBHOOKS_FILE.exists(): return [] try: return json.loads(WEBHOOKS_FILE.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return [] def _write(webhooks: list): WEBHOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) tmp = WEBHOOKS_FILE.with_suffix(".tmp") tmp.write_text(json.dumps(webhooks, indent=2, default=str), encoding="utf-8") tmp.replace(WEBHOOKS_FILE) def get_webhooks() -> list: return _read() def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict: webhooks = _read() wh = { "id": str(uuid.uuid4()), "name": name, "url": url, "events": [e for e in events if e in VALID_EVENTS], "secret": secret, "enabled": True, "created_at": datetime.now(timezone.utc).isoformat(), "last_fired_at": None, } webhooks.append(wh) _write(webhooks) logger.info(f"Created webhook '{name}' → {url}") return wh def update_webhook(wh_id: str, updates: dict) -> dict | None: webhooks = _read() for wh in webhooks: if wh["id"] == wh_id: wh.update({k: v for k, v in updates.items() if k != "id"}) _write(webhooks) return wh return None def delete_webhook(wh_id: str) -> bool: webhooks = _read() new_list = [wh for wh in webhooks if wh["id"] != wh_id] if len(new_list) == len(webhooks): return False _write(new_list) return True async def dispatch_webhooks(event_type: str, data: dict): """Fire all enabled webhooks subscribed to event_type.""" webhooks = _read() targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])] if not targets: return payload = { "event": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), "data": data, } body = json.dumps(payload, default=str) async def _post(wh): try: headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type} if wh.get("secret"): sig = hmac.new(wh["secret"].encode(), body.encode(), hashlib.sha256).hexdigest() headers["X-ObsiGate-Signature"] = f"sha256={sig}" timeout = aiohttp.ClientTimeout(total=5) async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp: if resp.status < 400: logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})") else: logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})") update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()}) except Exception as e: logger.warning(f"Webhook '{wh['name']}' error: {e}") tasks = [asyncio.create_task(_post(wh)) for wh in targets] # Don't await — fire and forget (webhooks should not block the main thread) # But we do register them so they run in background for task in tasks: task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)