Files
ObsiGate/backend/notify.py
T
bruno 4ce677902a
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s
feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
2026-10-04 13:11:25 -04:00

351 lines
14 KiB
Python

"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
Configuration is persisted in ``data/notify_channels.json``; secrets live in
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
environment variables — never in the public config file (same pattern as
``backend/webhooks.py``, BUG-026).
Supported channel types:
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
stays on the historical ``backend/webhooks.py`` path.
"""
from __future__ import annotations
import json
import logging
import os
import smtplib
import threading
import uuid
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.notify")
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
_lock = threading.RLock()
# ── Store helpers ──────────────────────────────────────────────────────────
def _read_channels() -> list[dict[str, Any]]:
if not CHANNELS_FILE.exists():
return []
try:
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
return []
def _write_channels(channels: list[dict[str, Any]]) -> None:
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = CHANNELS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
tmp.replace(CHANNELS_FILE)
def _read_secrets() -> dict[str, str]:
if not SECRETS_FILE.exists():
return {}
try:
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _write_secrets(secrets: dict[str, str]) -> None:
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = SECRETS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
tmp.replace(SECRETS_FILE)
try:
SECRETS_FILE.chmod(0o600)
except OSError:
pass # Windows: pas de permissions Unix
def _secret_key(channel_id: str) -> str:
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
def _get_secret(channel_id: str) -> str | None:
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
env_val = os.environ.get(_secret_key(channel_id))
if env_val:
return env_val
stored = _read_secrets().get(channel_id)
if stored:
return stored
for ch in _read_channels():
if ch.get("id") == channel_id:
cfg = ch.get("config", {})
for key in ("webhook_url", "bot_token", "password"):
if cfg.get(key):
return str(cfg[key])
return None
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
clean = {k: v for k, v in channel.items() if k != "config"}
cfg = dict(channel.get("config", {}))
for secret_field in ("webhook_url", "bot_token", "password"):
if cfg.get(secret_field):
cfg[secret_field] = "***"
clean["config"] = cfg
clean["has_secret"] = bool(_get_secret(channel["id"]))
return clean
# ── CRUD ───────────────────────────────────────────────────────────────────
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
config = dict(config or {})
if channel_type == "discord":
url = str(config.get("webhook_url") or config.get("url") or "").strip()
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
from backend.webhooks import validate_webhook_url
validate_webhook_url(url)
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
config["webhook_url"] = url
elif channel_type == "telegram":
if not str(config.get("chat_id") or "").strip():
raise ValueError("chat_id Telegram requis")
config["chat_id"] = str(config["chat_id"]).strip()
if config.get("bot_token"):
config["bot_token"] = str(config["bot_token"]).strip()
elif channel_type == "smtp":
for field in ("host", "from_addr", "to_addr"):
if not str(config.get(field) or "").strip():
raise ValueError(f"Champ SMTP requis : {field}")
config["port"] = int(config.get("port") or 587)
config["use_tls"] = bool(config.get("use_tls", True))
config["username"] = str(config.get("username") or "").strip()
elif channel_type == "webhook":
from backend.webhooks import validate_webhook_url
url = str(config.get("url") or "").strip()
validate_webhook_url(url)
config["url"] = url
else:
raise ValueError(f"Type de canal inconnu : {channel_type}")
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
config["triggers"] = triggers or ["manual"]
return config
def list_channels() -> list[dict[str, Any]]:
"""Return public views of all notification channels."""
return [_public_view(ch) for ch in _read_channels()]
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Create a notification channel. Secrets are split into the secret store."""
if channel_type not in CHANNEL_TYPES:
raise ValueError(f"Type de canal inconnu : {channel_type}")
with _lock:
channels = _read_channels()
channel_id = str(uuid.uuid4())
normalized = _validate_config(channel_type, config)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if normalized.get(field) and len(str(normalized[field])) > 8:
secrets[channel_id] = str(normalized[field])
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
_write_secrets(secrets)
channel = {
"id": channel_id,
"name": (name or channel_type).strip() or channel_type,
"type": channel_type,
"enabled": True,
"config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_sent_at": None,
"last_error": None,
}
channels.append(channel)
_write_channels(channels)
logger.info(f"Created notify channel '{name}' ({channel_type})")
return _public_view(channel)
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
"""Update a channel (name/enabled/config). Returns None when unknown."""
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") != channel_id:
continue
if updates.get("name"):
channel["name"] = str(updates["name"])
if "enabled" in updates:
channel["enabled"] = bool(updates["enabled"])
if "config" in updates and isinstance(updates["config"], dict):
merged = {**channel.get("config", {}), **updates["config"]}
normalized = _validate_config(channel["type"], merged)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if updates["config"].get(field):
secrets[channel_id] = str(updates["config"][field])
normalized[field] = "***"
_write_secrets(secrets)
channel["config"] = normalized
_write_channels(channels)
return _public_view(channel)
return None
def delete_channel(channel_id: str) -> bool:
"""Delete a channel and its secret. Returns False when unknown."""
with _lock:
channels = _read_channels()
remaining = [c for c in channels if c.get("id") != channel_id]
if len(remaining) == len(channels):
return False
_write_channels(remaining)
secrets = _read_secrets()
if secrets.pop(channel_id, None) is not None:
_write_secrets(secrets)
return True
# ── Dispatch ───────────────────────────────────────────────────────────────
def _send_discord(webhook_url: str, title: str, message: str) -> None:
import httpx
content = f"**{title}**\n{message}"[:2000]
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
resp.raise_for_status()
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
import httpx
from backend.webhooks import is_safe_target
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
if not is_safe_target(url):
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
text = f"*{title}*\n{message}"[:4000]
resp = httpx.post(
url,
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
timeout=10.0,
)
resp.raise_for_status()
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
msg = EmailMessage()
msg["Subject"] = f"[ObsiGate] {title}"
msg["From"] = config["from_addr"]
msg["To"] = config["to_addr"]
msg.set_content(message)
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
if config.get("use_tls", True):
client.starttls()
if config.get("username") and password:
client.login(str(config["username"]), password)
client.send_message(msg)
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
import httpx
from backend.webhooks import is_safe_target
if not is_safe_target(url):
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
resp = httpx.post(
url,
json={
"event": trigger,
"title": title,
"message": message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "obsigate-notify",
},
timeout=10.0,
)
resp.raise_for_status()
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
"""Send a notification through one raw channel record. Raises on failure."""
channel_type = channel.get("type")
cfg = dict(channel.get("config", {}))
secret = _get_secret(channel["id"])
if channel_type == "discord":
url = secret or cfg.get("webhook_url") or ""
if not url or url == "***":
raise RuntimeError("URL webhook Discord manquante")
_send_discord(url, title, message)
elif channel_type == "telegram":
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
if not token or token == "***":
raise RuntimeError("Token bot Telegram manquant")
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
elif channel_type == "smtp":
_send_smtp(cfg, secret, title, message)
elif channel_type == "webhook":
url = str(cfg.get("url") or "").strip()
if not url:
raise RuntimeError("URL webhook manquante")
_send_webhook(url, title, message, trigger)
else:
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
results: list[dict[str, Any]] = []
for channel in _read_channels():
if not channel.get("enabled", True):
continue
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
continue
try:
send_via_channel(channel, title, message, trigger)
results.append({"channel_id": channel["id"], "ok": True})
_mark_sent(channel["id"], None)
except Exception as e:
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
_mark_sent(channel["id"], str(e))
return results
def _mark_sent(channel_id: str, error: str | None) -> None:
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") == channel_id:
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
channel["last_error"] = error
_write_channels(channels)