"""Notifier — envoi de notifications via bot Telegram. Configuration globale (environment variables, lue depuis .env.prod via docker-compose) : - TELEGRAM_BOT_TOKEN : token du bot (obtenu via @BotFather) - TELEGRAM_CHAT_ID : chat id par défaut (obtenu via getUpdates ou @userinfobot) Override par job possible via le champ CronJob.notify_chat_id. """ from __future__ import annotations import json import os import urllib.error import urllib.parse import urllib.request from pathlib import Path _TG_API = "https://api.telegram.org" def _bot_token() -> str: return os.getenv("TELEGRAM_BOT_TOKEN", "").strip() def _default_chat_id() -> str: return os.getenv("TELEGRAM_CHAT_ID", "").strip() def send_telegram(text: str, chat_id: str | None = None, *, parse_mode: str = "HTML") -> bool: """Envoie un message Telegram. Retourne True si succès, False sinon. parse_mode="HTML" supporte , , ,
.
    Tronque automatiquement à 4096 caractères (limite Telegram).
    """
    token = _bot_token()
    chat = (chat_id or "").strip() or _default_chat_id()
    if not token or not chat:
        return False

    text = text[:4090] + "\n…" if len(text) > 4096 else text
    url = f"{_TG_API}/bot{token}/sendMessage"
    data = urllib.parse.urlencode({
        "chat_id": chat,
        "text": text,
        "parse_mode": parse_mode,
        "disable_web_page_preview": "true",
    }).encode()

    try:
        req = urllib.request.Request(url, data=data, method="POST")
        with urllib.request.urlopen(req, timeout=15) as r:
            payload = json.loads(r.read())
            return bool(payload.get("ok"))
    except urllib.error.HTTPError as e:
        try:
            err_body = e.read().decode()
        except Exception:
            err_body = str(e)
        print(f"[notifier] Telegram HTTPError {e.code}: {err_body[:200]}")
        return False
    except Exception as e:
        print(f"[notifier] Telegram error: {e}")
        return False


def notify_job_result(
    job_name: str,
    status: str,
    message: str,
    log_path: Path | str | None = None,
    chat_id: str | None = None,
    notify_on: str = "failure",
    notify_level: str = "normal",
    duration_s: float | None = None,
    details: dict | None = None,
    job_options: dict | None = None,
) -> bool:
    """Envoie une notif Telegram selon le statut et la politique notify_on.

    Args:
        job_name: nom du cron job
        status: "ok" | "fail"
        message: message court (1-2 lignes)
        log_path: ignoré au niveau "normal" et "detailed" (l'utilisateur n'en veut pas)
        chat_id: override du chat id (sinon TELEGRAM_CHAT_ID)
        notify_on: "never" | "always" | "success" | "failure"
        notify_level: "normal" (nom + statut + durée) ou "detailed" (+ détails import)
        duration_s: durée d'exécution en secondes
        details: dict avec clés optionnelles depuis sync_last_result.json :
            - res_abs: list[dict avec classe, nouvelles, mises_a_jour, pending_skipped]
            - res_bn:  list[dict avec classe, nb]
            - res_notes: list[dict avec classe, nb]
            - res_matu: list[dict avec classe, nb]
            - errors: list[str]
        job_options: dict avec options du job pour savoir ce qui était sélectionné :
            - sync_bn, sync_notes, sync_fiches, etc. (booléens)
    """
    if notify_on == "never":
        return False
    if notify_on == "success" and status != "ok":
        return False
    if notify_on == "failure" and status == "ok":
        return False

    icon = "✅" if status == "ok" else "❌"
    title = "Réussi" if status == "ok" else "Échec"
    parts = [
        f"{icon} {_escape_html(job_name)} — {title}",
    ]
    if duration_s is not None:
        parts.append(f"⏱ Durée : {_fmt_duration(duration_s)}")

    # Niveau normal — message court uniquement
    if notify_level != "detailed":
        if message and status != "ok":
            # En cas d'échec, on garde le message d'erreur même en normal
            msg = message.strip()
            if len(msg) > 500:
                msg = msg[:500] + "…"
            parts.append(f"
{_escape_html(msg)}
") return send_telegram("\n".join(parts), chat_id=chat_id) # Niveau detailed — détails par classe et catégorie job_options = job_options or {} details = details or {} # Erreurs en premier si présentes errors = details.get("errors") or [] if errors: parts.append("\n⚠ Erreurs") for err in errors[:10]: parts.append(f" • {_escape_html(str(err)[:200])}") if len(errors) > 10: parts.append(f" … +{len(errors) - 10} autre(s)") # Absences (toujours affichées si présentes) res_abs = details.get("res_abs") or [] if res_abs: parts.append("\n📋 Absences") for r in res_abs: classe = r.get("classe", "?") nouv = int(r.get("nouvelles", 0) or 0) maj = int(r.get("mises_a_jour", 0) or 0) pend = int(r.get("pending_skipped", 0) or 0) orph = int(r.get("orphelines", 0) or 0) line = ( f" • {_escape_html(classe)} : " f"{nouv} nouv. · {maj} modif. · {pend} pending" ) if orph: line += f" · {orph} suppr." parts.append(line) # Liste des orphelines (cap à 5 par classe pour limiter la taille) details_orph = r.get("details_orphelines") or [] if details_orph: for d in details_orph[:5]: parts.append(f" ↳ {_escape_html(str(d))}") if len(details_orph) > 5: parts.append(f" ↳ … +{len(details_orph) - 5} autre(s)") # BN (seulement si sync_bn coché) if job_options.get("sync_bn"): res_bn = details.get("res_bn") or [] parts.append("\n📊 Bulletins") if res_bn: for r in res_bn: parts.append( f" • {_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)" ) else: parts.append(" Aucun import") # Notes d'examen (seulement si sync_notes coché) if job_options.get("sync_notes"): res_notes = details.get("res_notes") or [] parts.append("\n📝 Notes d'examen") if res_notes: for r in res_notes: parts.append( f" • {_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)" ) else: parts.append(" Aucun import") # Notes Matu (seulement si BN coché — Matu est lié aux apprentis BN) if job_options.get("sync_bn"): res_matu = details.get("res_matu") or [] if res_matu: parts.append("\n🎓 Matu") for r in res_matu[:8]: # cap à 8 pour pas exploser le message parts.append( f" • {_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)" ) if len(res_matu) > 8: parts.append(f" … +{len(res_matu) - 8} classe(s)") return send_telegram("\n".join(parts), chat_id=chat_id) def _fmt_duration(seconds: float) -> str: s = int(seconds) if s < 60: return f"{s}s" if s < 3600: return f"{s // 60}min {s % 60}s" return f"{s // 3600}h {(s % 3600) // 60}min" def _escape_html(s: str) -> str: return ( s.replace("&", "&") .replace("<", "<") .replace(">", ">") ) def test_telegram() -> tuple[bool, str]: """Test rapide : envoie un message de ping. Retourne (ok, message).""" if not _bot_token(): return False, "TELEGRAM_BOT_TOKEN non configuré dans l'environnement" if not _default_chat_id(): return False, "TELEGRAM_CHAT_ID non configuré dans l'environnement" ok = send_telegram("✓ EPTM Dashboard\nTest de notification — tout est OK.") if ok: return True, "Message envoyé" return False, "Échec de l'envoi (voir logs container)"