121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
"""É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
|