""" 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 json import hmac import hashlib import asyncio import logging import uuid from pathlib import Path from datetime import datetime, timezone from typing import Optional, List 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: Optional[str] = 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) -> Optional[dict]: 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: async with 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)