Add ntfy push notification system with comprehensive configuration, API endpoints, and automatic event notifications for tasks, schedules, health checks, and bootstrap operations
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Services métier pour l'API Homelab Automation.
|
||||
"""
|
||||
|
||||
from .notification_service import NotificationService, notification_service
|
||||
|
||||
__all__ = [
|
||||
"NotificationService",
|
||||
"notification_service",
|
||||
]
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user