homelab_automation/app/services/notification_service.py

524 lines
17 KiB
Python

"""
Service de notification ntfy pour l'API Homelab Automation.
Ce service gère l'envoi de notifications push via ntfy de manière asynchrone,
non-bloquante et robuste (gestion d'erreurs, timeouts, authentification).
Usage:
from services.notification_service import notification_service
# Envoi simple
await notification_service.send(
topic="homelab-backup",
message="Backup terminé avec succès",
title="✅ Backup OK"
)
# Avec BackgroundTasks FastAPI
background_tasks.add_task(
notification_service.send,
topic="homelab-backup",
message="Backup terminé"
)
"""
import json
import logging
from typing import Optional, List, Dict, Any
from base64 import b64encode
import httpx
from schemas.notification import (
NtfyConfig,
NtfyAction,
NotificationRequest,
NotificationResponse,
NotificationTemplates,
)
# Logger dédié pour le service de notification
logger = logging.getLogger("homelab.notifications")
class NotificationService:
"""
Service de notification ntfy asynchrone et non-bloquant.
Caractéristiques:
- Async avec httpx.AsyncClient
- Timeout configurable
- Gestion d'erreur robuste (ne lève jamais d'exception bloquante)
- Support authentification Basic et Bearer
- Logs structurés pour debugging
"""
def __init__(self, config: Optional[NtfyConfig] = None):
"""
Initialise le service avec une configuration.
Args:
config: Configuration ntfy. Si None, charge depuis les variables d'env.
"""
self._config = config or NtfyConfig.from_env()
self._client: Optional[httpx.AsyncClient] = None
self.templates = NotificationTemplates
@property
def config(self) -> NtfyConfig:
"""Retourne la configuration actuelle."""
return self._config
@property
def enabled(self) -> bool:
"""Vérifie si les notifications sont activées."""
return self._config.enabled
def reconfigure(self, config: NtfyConfig) -> None:
"""
Reconfigure le service avec une nouvelle configuration.
Args:
config: Nouvelle configuration ntfy
"""
self._config = config
# Fermer le client existant pour forcer une reconnexion
if self._client:
# Note: on ne peut pas await ici, le client sera recréé au prochain appel
self._client = None
logger.info(f"[NTFY] Service reconfiguré: base_url={config.base_url}, enabled={config.enabled}")
async def _get_client(self) -> httpx.AsyncClient:
"""Retourne un client HTTP réutilisable."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._config.timeout),
follow_redirects=True
)
return self._client
def _build_auth_headers(self) -> Dict[str, str]:
"""Construit les headers d'authentification si configurés."""
headers = {}
if self._config.token:
headers["Authorization"] = f"Bearer {self._config.token}"
elif self._config.username and self._config.password:
credentials = f"{self._config.username}:{self._config.password}"
encoded = b64encode(credentials.encode()).decode()
headers["Authorization"] = f"Basic {encoded}"
return headers
def _should_send(self, level: str) -> bool:
"""Détermine si une notification d'un certain niveau doit être envoyée.
Args:
level: Niveau logique de la notification: "INFO", "WARN" ou "ERR".
Returns:
True si le niveau est autorisé par la configuration NTFY_MSG_TYPE.
"""
level_up = level.upper()
allowed = self._config.allowed_levels
if level_up not in {"INFO", "WARN", "ERR"}:
return True
return level_up in allowed
def _build_json_payload(
self,
message: str,
topic: str,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
click: Optional[str] = None,
attach: Optional[str] = None,
delay: Optional[str] = None,
actions: Optional[List[NtfyAction]] = None,
) -> Dict[str, Any]:
"""
Construit le payload JSON pour ntfy.
L'envoi en JSON permet d'utiliser des caractères UTF-8 (accents, emojis)
dans le titre et les tags, contrairement aux headers HTTP qui sont limités à ASCII.
Args:
message: Corps du message
topic: Topic cible
title: Titre de la notification
priority: Priorité (1-5)
tags: Liste de tags/emojis
click: URL au clic
attach: URL pièce jointe
delay: Délai d'envoi
actions: Actions attachées
Returns:
Dictionnaire JSON à envoyer
"""
payload: Dict[str, Any] = {
"topic": topic,
"message": message,
}
if title:
payload["title"] = title
if priority is not None:
payload["priority"] = priority
if tags:
payload["tags"] = tags
if click:
payload["click"] = click
if attach:
payload["attach"] = attach
if delay:
payload["delay"] = delay
if actions:
payload["actions"] = [
{
"action": act.action,
"label": act.label,
**({
"url": act.url,
} if act.url else {}),
**({
"method": act.method,
} if act.method else {}),
**({
"clear": act.clear,
} if act.clear else {}),
}
for act in actions
]
return payload
async def send(
self,
message: str,
topic: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
click: Optional[str] = None,
attach: Optional[str] = None,
delay: Optional[str] = None,
actions: Optional[List[NtfyAction]] = None,
) -> bool:
"""
Envoie une notification à ntfy.
Cette méthode ne lève JAMAIS d'exception - elle retourne False en cas d'erreur
et log le problème. Cela garantit qu'une notification échouée ne bloque pas
l'opération principale.
Args:
message: Corps du message (obligatoire)
topic: Topic cible (utilise default_topic si non spécifié)
title: Titre de la notification
priority: Priorité 1-5 (1=min, 3=default, 5=urgent)
tags: Liste de tags/emojis ntfy
click: URL à ouvrir au clic
attach: URL d'une pièce jointe
delay: Délai avant envoi (ex: "30m", "1h")
actions: Liste d'actions attachées
Returns:
True si l'envoi a réussi, False sinon
"""
# Vérifier si les notifications sont activées
if not self._config.enabled:
logger.debug("[NTFY] Notifications désactivées, message ignoré")
return True # On considère ça comme un "succès" car c'est intentionnel
# Utiliser le topic par défaut si non spécifié
target_topic = topic or self._config.default_topic
# Construire l'URL de base (sans le topic, car il est dans le JSON)
url = self._config.base_url.rstrip('/')
# Construire le payload JSON (supporte UTF-8 dans le titre et les tags)
payload = self._build_json_payload(
message=message,
topic=target_topic,
title=title,
priority=priority,
tags=tags,
click=click,
attach=attach,
delay=delay,
actions=actions,
)
# Headers: uniquement auth + Content-Type JSON
headers = self._build_auth_headers()
headers["Content-Type"] = "application/json"
try:
client = await self._get_client()
logger.debug(f"[NTFY] Envoi JSON vers {url}: {message[:50]}...")
response = await client.post(
url,
content=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
)
if response.status_code in (200, 201):
logger.info(f"[NTFY] ✓ Notification envoyée: topic={target_topic}, title={title or '(none)'}")
return True
else:
logger.warning(
f"[NTFY] ✗ Échec envoi: status={response.status_code}, "
f"body={response.text[:200]}"
)
return False
except httpx.TimeoutException:
logger.warning(f"[NTFY] ✗ Timeout après {self._config.timeout}s pour {url}")
return False
except httpx.ConnectError as e:
logger.warning(f"[NTFY] ✗ Connexion impossible à {url}: {e}")
return False
except Exception as e:
logger.error(f"[NTFY] ✗ Erreur inattendue: {type(e).__name__}: {e}")
return False
async def send_request(self, request: NotificationRequest) -> NotificationResponse:
"""
Envoie une notification à partir d'un objet NotificationRequest.
Args:
request: Objet NotificationRequest avec tous les paramètres
Returns:
NotificationResponse avec le résultat
"""
topic = request.topic or self._config.default_topic
success = await self.send(
message=request.message,
topic=topic,
title=request.title,
priority=request.priority,
tags=request.tags,
click=request.click,
attach=request.attach,
delay=request.delay,
actions=request.actions,
)
return NotificationResponse(
success=success,
topic=topic,
error=None if success else "Échec de l'envoi (voir logs)"
)
# ===== Méthodes helper pour les cas d'usage courants =====
async def notify_backup_success(
self,
hostname: str,
duration: Optional[str] = None,
size: Optional[str] = None
) -> bool:
"""Notification de succès de backup."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification backup_success ignorée (niveau INFO filtré)")
return True
req = self.templates.backup_success(hostname, duration, size)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_backup_failed(self, hostname: str, error: str) -> bool:
"""Notification d'échec de backup."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification backup_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.backup_failed(hostname, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_started(self, hostname: str) -> bool:
"""Notification de début de bootstrap."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification bootstrap_started ignorée (niveau INFO filtré)")
return True
req = self.templates.bootstrap_started(hostname)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_success(self, hostname: str) -> bool:
"""Notification de succès de bootstrap."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification bootstrap_success ignorée (niveau INFO filtré)")
return True
req = self.templates.bootstrap_success(hostname)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_bootstrap_failed(self, hostname: str, error: str) -> bool:
"""Notification d'échec de bootstrap."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification bootstrap_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.bootstrap_failed(hostname, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_health_changed(
self,
hostname: str,
new_status: str,
details: Optional[str] = None
) -> bool:
"""Notification de changement d'état de santé."""
status = "up" if new_status.lower() in ("up", "online", "healthy") else "down"
level = "WARN" if status == "down" else "INFO"
if not self._should_send(level):
logger.debug("[NTFY] Notification health_changed ignorée (niveau %s filtré)", level)
return True
req = self.templates.health_status_changed(hostname, status, details)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_task_completed(
self,
task_name: str,
target: str,
duration: Optional[str] = None
) -> bool:
"""Notification de tâche terminée."""
if not self._should_send("INFO"):
logger.debug("[NTFY] Notification task_completed ignorée (niveau INFO filtré)")
return True
req = self.templates.task_completed(task_name, target, duration)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_task_failed(
self,
task_name: str,
target: str,
error: str
) -> bool:
"""Notification d'échec de tâche."""
if not self._should_send("ERR"):
logger.debug("[NTFY] Notification task_failed ignorée (niveau ERR filtré)")
return True
req = self.templates.task_failed(task_name, target, error)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def notify_schedule_executed(
self,
schedule_name: str,
success: bool,
details: Optional[str] = None
) -> bool:
"""Notification d'exécution de schedule."""
level = "INFO" if success else "ERR"
if not self._should_send(level):
logger.debug("[NTFY] Notification schedule_executed ignorée (niveau %s filtré)", level)
return True
req = self.templates.schedule_executed(schedule_name, success, details)
return await self.send(
message=req.message,
topic=req.topic,
title=req.title,
priority=req.priority,
tags=req.tags,
)
async def close(self) -> None:
"""Ferme le client HTTP proprement."""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("[NTFY] Client HTTP fermé")
# Instance globale du service (singleton)
# Initialisée avec la config depuis les variables d'environnement
notification_service = NotificationService()
# ===== Fonctions utilitaires pour usage direct =====
async def send_notification(
message: str,
topic: Optional[str] = None,
title: Optional[str] = None,
priority: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> bool:
"""
Fonction utilitaire pour envoyer une notification rapidement.
Utilise l'instance globale du service.
Idéal pour les BackgroundTasks FastAPI.
Example:
background_tasks.add_task(
send_notification,
message="Backup terminé",
topic="homelab-backup",
title="✅ Backup OK"
)
"""
return await notification_service.send(
message=message,
topic=topic,
title=title,
priority=priority,
tags=tags,
)