"""Émission d'événements vers le journal du service d'agents (Fusion, Lot 2 — contrat ACC-0.2). Flowdeck émet ses mutations significatives (pages et collections) vers `POST /api/v1/evenements` du service d'agents, qui les expose dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne contient qu'une référence, un libellé et un lien — jamais le contenu de l'objet : {id, type, occurred_at, actor, domain, source_app, object{ref, label, url}}. Authentification : signature HMAC-SHA256 du corps brut, en-tête `X-Draco-Signature`, avec la clé propre à Flowdeck (`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est INACTIF : aucun appel, aucun bruit. Discipline « au mieux » (même esprit que le journal du service) : l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s, et toute erreur est avalée avec une ligne de journal — une panne du récepteur ne doit jamais casser ni ralentir le métier. Aucun événement « meeting » n'est émis ici : la décision A/B de Bruno sur le bloc meeting de Flowdeck est toujours ouverte. """ from __future__ import annotations import hashlib import hmac import json import logging import os import threading import uuid from datetime import datetime, timezone import httpx log = logging.getLogger("flowdeck.evenements") SOURCE_APP = "flowdeck" URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements" DELAI_S = 2.0 def _cle() -> str: return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip() def _url() -> str: return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT def actif() -> bool: """L'émission n'existe que si la clé de Flowdeck est configurée.""" return bool(_cle()) def url_page(page_id: int) -> str: """Lien profond d'une page d'espace de travail (vue HTML du board).""" return f"/board/pages/{page_id}" def url_collection(collection_id: int) -> str: """Lien profond d'une collection (sa vue tableau). Une page de collection n'a pas d'URL autonome : elle s'ouvre dans cette vue.""" return f"/db/{collection_id}/view/table" def construire_enveloppe(type_: str, objet_ref: str, label: str, url: str = "/", domain: str = "pro", actor: str | None = None) -> tuple[dict, bytes]: """L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est signé puis envoyé — la signature porte sur ces octets-là).""" env = { "id": uuid.uuid4().hex, "type": type_, "occurred_at": datetime.now(timezone.utc).isoformat(), "actor": actor or "user", "domain": domain or "pro", "source_app": SOURCE_APP, "object": {"ref": objet_ref, "label": label or "", "url": url or "/"}, } corps = json.dumps(env, ensure_ascii=False).encode("utf-8") return env, corps def _signer(corps: bytes) -> str: return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest() def _envoyer(corps: bytes) -> None: """Le POST signé, dans le fil d'arrière-plan. Avale tout : le journal distant est un confort, jamais une dépendance.""" try: with httpx.Client(timeout=DELAI_S, trust_env=False) as client: client.post( _url(), content=corps, headers={"Content-Type": "application/json", "X-Draco-Signature": _signer(corps)}, ) except Exception as exc: # panne, délai, DNS… : on log et on passe log.warning("événement non livré au journal du service (%s)", exc) def emettre(type_: str, objet_ref: str, label: str, url: str = "/", domain: str = "pro", actor: str | None = None) -> bool: """Émet un événement en tâche de fond. Retourne True si l'envoi a été déclenché, False si le module est inactif (pas de clé) ou si l'enveloppe n'a pas pu être construite. Ne lève jamais.""" if not actif(): return False try: _env, corps = construire_enveloppe(type_, objet_ref, label, url, domain, actor) except Exception: log.warning("événement %s non construit", type_, exc_info=True) return False threading.Thread(target=_envoyer, args=(corps,), daemon=True, name="flowdeck-evenements").start() return True