"""Service Microsoft Teams — deux modes d'envoi : 1. **mode='sharepoint'** (recommandé pour SANEF) — écriture d'un fichier .txt dans un sous-dossier OneDrive synchronisé. Un Workflow Power Automate côté SharePoint surveille le dossier et poste sur Teams. Aucun OAuth requis. Format identique au .exe Sanef Patch Manager pour réutiliser les Workflows existants. 2. **mode='webhook'** (futur, OAuth requis sur tenant SANEF) — POST direct sur l'URL Power Automate Direct API avec un Bearer token Entra ID. Le routage serveur → canal/conversation est géré par la table `teams_channel_rules` (rules-based) et résolu par `resolve_channel_for_server`. Mode 'sharepoint' avec is_dynamic_dm=true : le fichier est préfixé par une ligne `TO: ` que le Workflow PA lit pour poster dynamiquement à un destinataire, sans avoir à créer un workflow par responsable. """ import json import logging import os import threading from datetime import datetime from typing import Dict, Any, Optional, List import requests log = logging.getLogger("patchcenter.teams") HTTP_TIMEOUT = 10 PROXY_URL = None # à override via Settings si besoin # ─────────────────────────────────────────────────────────────── # MODE SHAREPOINT — écriture fichier dans dossier OneDrive sync # ─────────────────────────────────────────────────────────────── def format_message_text(server_name: str, msg_type: str, intervenant: str) -> str: """Formate le message texte plat (compatible workflow PA → Teams). Format identique au .exe Sanef Patch Manager pour réutiliser les workflows existants côté SharePoint.""" nom = (intervenant or "SecOps") nom = nom[:1].upper() + nom[1:] if nom else "SecOps" if msg_type == "debut": return f"[SECOPS] : Intervenant({nom}) => Debut d'intervention sur {server_name}" if msg_type == "reboot": return f"[SECOPS] : Intervenant({nom}) => Reboot suite MAJ de {server_name}" if msg_type == "fin": return f"[SECOPS] : Intervenant({nom}) => Fin d'intervention sur {server_name}" if msg_type == "annulation": return f"[SECOPS] : Intervenant({nom}) => Annulation intervention sur {server_name}" return f"[SECOPS] : Intervenant({nom}) => {msg_type} {server_name}" def write_sharepoint_notification(sp_base: str, sp_route: str, msg_type: str, server_name: str, intervenant: str, dynamic_to_email: Optional[str] = None) -> Dict[str, Any]: """Écrit un fichier .txt dans //. Format nom de fichier : __.txt Format contenu : - Si dynamic_to_email : 1ère ligne `TO: `, puis le message - Sinon : juste le message texte plat Le sous-dossier est créé s'il n'existe pas. Retourne {ok, path, detail} ou {ok: False, msg}.""" if not sp_base or not sp_route: return {"ok": False, "msg": "sharepoint_notif_path ou sp_route manquant"} body = format_message_text(server_name, msg_type, intervenant) if not body: return {"ok": False, "msg": f"msg_type inconnu: {msg_type}"} if dynamic_to_email: content = f"TO: {dynamic_to_email}\n{body}\n" else: content = body + "\n" try: notif_dir = os.path.join(sp_base, sp_route) os.makedirs(notif_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") # Nettoyage hostname pour nom fichier safe_host = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in (server_name or "unknown"))[:80] fname = f"{msg_type}_{safe_host}_{ts}.txt" fpath = os.path.join(notif_dir, fname) with open(fpath, "w", encoding="utf-8") as f: f.write(content) log.info("Teams SP notif écrite: %s", fpath) return {"ok": True, "path": fpath, "detail": "Fichier écrit, en attente sync OneDrive"} except Exception as e: log.error("Échec écriture SP notif: %s", e) return {"ok": False, "msg": f"Erreur écriture: {e}"} def write_sharepoint_notification_async(sp_base: str, sp_route: str, msg_type: str, server_name: str, intervenant: str, dynamic_to_email: Optional[str] = None) -> None: """Variante non-bloquante : lance write_sharepoint_notification dans un thread.""" threading.Thread( target=write_sharepoint_notification, args=(sp_base, sp_route, msg_type, server_name, intervenant, dynamic_to_email), daemon=True, ).start() # ─────────────────────────────────────────────────────────────── # MODE WEBHOOK — POST direct (futur, OAuth requis) # ─────────────────────────────────────────────────────────────── def _proxies(): if PROXY_URL: return {"http": PROXY_URL, "https": PROXY_URL} return None def _post(webhook_url: str, payload: Dict[str, Any]) -> Dict[str, Any]: """Poste un payload sur le webhook Teams. Retourne {ok, status, detail}.""" try: r = requests.post(webhook_url, json=payload, timeout=HTTP_TIMEOUT, proxies=_proxies()) ok = (200 <= r.status_code < 300) return { "ok": ok, "status": r.status_code, "detail": (r.text or "")[:500] if not ok else "Message envoyé", } except requests.exceptions.RequestException as e: return {"ok": False, "status": 0, "detail": f"Réseau: {e}"} def _adaptive_card(title: str, body_lines: list, color: str = "good") -> Dict[str, Any]: """Construit un payload Adaptive Card minimal (compatible Workflows). color : 'good' (vert) | 'warning' (orange) | 'attention' (rouge) | 'default'.""" facts = [] body_blocks = [ {"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium", "color": color, "wrap": True} ] for ln in body_lines: if isinstance(ln, dict) and "title" in ln and "value" in ln: facts.append({"title": ln["title"], "value": str(ln["value"])}) else: body_blocks.append({"type": "TextBlock", "text": str(ln), "wrap": True, "spacing": "Small"}) if facts: body_blocks.append({"type": "FactSet", "facts": facts}) body_blocks.append({"type": "TextBlock", "text": f"_{datetime.now().strftime('%Y-%m-%d %H:%M')} — PatchCenter_", "size": "Small", "isSubtle": True, "spacing": "Medium"}) return { "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": None, "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": body_blocks, } }] } def resolve_channel_for_server(db, server_id: int, msg_type: str = "debut") -> Dict[str, Any]: """Résout le canal Teams à utiliser pour un serveur, selon le msg_type. Algorithme : 1. Si msg_type='reboot' → canal flaggé `is_reboot_channel=true` (priorité absolue) 2. servers.teams_channel_id (override explicite) 3. teams_channel_rules (ordre `priority ASC`, première règle qui matche toutes les conditions actives) 4. teams_channels.is_default=true (fallback global) 5. Sinon → pas de notif Si le canal résolu a `is_dynamic_dm=true`, on récupère le `teams_upn` du `responsable_domaine_contact` du serveur pour préfixer le fichier `TO: `. Retourne { ok: bool, channel_id, name, mode, sp_route, webhook_url, is_dynamic_dm, dynamic_to_email (str | None), source ('reboot'|'server_override'|'rule:'|'default'), msg (si ok=False), } """ from sqlalchemy import text as sqlt if not server_id: return {"ok": False, "msg": "server_id manquant"} def _channel_row(channel_id): return db.execute(sqlt(""" SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm FROM teams_channels WHERE id = :id AND is_active = true """), {"id": channel_id}).fetchone() def _resolve_dynamic_to_email(server_row): """Récupère teams_upn du responsable_domaine pour DM dynamique.""" if not server_row.responsable_domaine_contact_id: return None c = db.execute(sqlt(""" SELECT teams_upn FROM contacts WHERE id = :id """), {"id": server_row.responsable_domaine_contact_id}).fetchone() return c.teams_upn if c and c.teams_upn else None # Récupère infos serveur (utilisé partout) s = db.execute(sqlt(""" SELECT s.id, s.hostname, s.environnement, s.application_id, s.responsable_domaine_contact_id, s.referent_technique_contact_id, s.teams_channel_id AS server_channel_id, a.domain AS app_domain, a.teams_channel_id AS app_channel_id FROM servers s LEFT JOIN applications a ON a.id = s.application_id WHERE s.id = :sid """), {"sid": server_id}).fetchone() if not s: return {"ok": False, "msg": f"Serveur id={server_id} introuvable"} def _build_result(ch, source): if not ch: return None dyn_email = _resolve_dynamic_to_email(s) if ch.is_dynamic_dm else None return { "ok": True, "channel_id": ch.id, "name": ch.name, "mode": ch.mode, "sp_route": ch.sp_route, "webhook_url": ch.webhook_url, "is_dynamic_dm": ch.is_dynamic_dm, "dynamic_to_email": dyn_email, "source": source, } # 1) Reboot → canal flaggé reboot (priorité absolue) if msg_type == "reboot": ch = db.execute(sqlt(""" SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm FROM teams_channels WHERE is_reboot_channel = true AND is_active = true ORDER BY id LIMIT 1 """)).fetchone() if ch: return _build_result(ch, "reboot") # Sinon on continue, peut-être un canal default routera # 2) Override par serveur if s.server_channel_id: ch = _channel_row(s.server_channel_id) if ch: return _build_result(ch, "server_override") # 3) Règles (ordre priority asc) rules = db.execute(sqlt(""" SELECT id, name, priority, channel_id, match_responsable_contact_id, match_application_domain, match_env_in, match_msg_type_in, match_hostname_pattern FROM teams_channel_rules WHERE is_active = true ORDER BY priority ASC, id ASC """)).fetchall() server_env = (s.environnement or "").strip() server_dom = (s.app_domain or "").strip() server_host = (s.hostname or "").lower() for r in rules: if r.match_msg_type_in and msg_type not in r.match_msg_type_in: continue if r.match_responsable_contact_id and \ s.responsable_domaine_contact_id != r.match_responsable_contact_id: continue if r.match_application_domain: md = r.match_application_domain.strip().lower() if not server_dom or md not in server_dom.lower(): continue if r.match_env_in: envs_lower = [e.lower() for e in r.match_env_in] if not server_env or server_env.lower() not in envs_lower: continue if r.match_hostname_pattern: pat = r.match_hostname_pattern.strip().lower() if pat and pat not in server_host: continue # Tout matche ch = _channel_row(r.channel_id) if ch: return _build_result(ch, f"rule:{r.name}") # 4) Default global ch = db.execute(sqlt(""" SELECT id, name, mode, sp_route, webhook_url, is_dynamic_dm FROM teams_channels WHERE is_default = true AND is_active = true ORDER BY id LIMIT 1 """)).fetchone() if ch: return _build_result(ch, "default") return {"ok": False, "msg": "Aucun canal Teams résolu (aucune règle ne matche, pas de canal défaut)"} def send_notification(db, server_id: int, msg_type: str, intervenant: str) -> Dict[str, Any]: """Orchestration haut niveau : résout le canal puis envoie la notif. Pour mode='sharepoint' : écrit le fichier dans le bon sous-dossier. Pour mode='webhook' : POST sur webhook_url (non implémenté tant qu'OAuth pas en place). Retourne le résultat de l'envoi (ok, detail, …) enrichi du contexte de résolution. """ from .secrets_service import get_secret res = resolve_channel_for_server(db, server_id, msg_type) if not res.get("ok"): return res server_name = db.execute(__import__("sqlalchemy").text( "SELECT hostname FROM servers WHERE id = :id" ), {"id": server_id}).scalar() or "unknown" if res["mode"] == "sharepoint": sp_base = (get_secret(db, "sharepoint_notif_path") or "").strip() if not sp_base: return {"ok": False, "msg": "sharepoint_notif_path non configuré dans Settings", "channel": res["name"]} out = write_sharepoint_notification( sp_base=sp_base, sp_route=res["sp_route"], msg_type=msg_type, server_name=server_name, intervenant=intervenant, dynamic_to_email=res.get("dynamic_to_email"), ) out["channel"] = res["name"] out["source"] = res["source"] return out if res["mode"] == "webhook": return {"ok": False, "msg": "Mode webhook non implémenté (OAuth Entra ID requis)", "channel": res["name"]} return {"ok": False, "msg": f"Mode inconnu: {res['mode']}"} # ─────────────────────────────────────────────────────────────── # Compat : helper webhook (à conserver pour le bouton "Tester" dans Settings) # ─────────────────────────────────────────────────────────────── def send_test_message(webhook_url: str, channel_name: str, sender: str) -> Dict[str, Any]: """Envoie un message de test pour valider la config du webhook.""" payload = _adaptive_card( title=f"✅ Test PatchCenter — canal '{channel_name}'", body_lines=[ "Ce message confirme que le webhook Teams est correctement configuré.", {"title": "Envoyé par", "value": sender}, {"title": "Source", "value": "PatchCenter > Settings > Teams"}, ], color="good", ) return _post(webhook_url, payload) def send_intervention_start(webhook_url: str, hostname: str, application: str, intervenant: str, planned_at: str = None) -> Dict[str, Any]: """Annonce le DÉBUT d'une intervention de patching.""" body = [ {"title": "Serveur", "value": hostname}, {"title": "Application", "value": application or "—"}, {"title": "Intervenant", "value": intervenant or "—"}, ] if planned_at: body.append({"title": "Prévu", "value": planned_at}) payload = _adaptive_card( title=f"🟧 Début intervention patching — {hostname}", body_lines=body, color="warning", ) return _post(webhook_url, payload) def send_intervention_end(webhook_url: str, hostname: str, application: str, intervenant: str, status: str = "ok") -> Dict[str, Any]: """Annonce la FIN d'une intervention. status = 'ok' | 'ko'.""" color = "good" if status == "ok" else "attention" icon = "✅" if status == "ok" else "❌" payload = _adaptive_card( title=f"{icon} Fin intervention patching — {hostname}", body_lines=[ {"title": "Serveur", "value": hostname}, {"title": "Application", "value": application or "—"}, {"title": "Intervenant", "value": intervenant or "—"}, {"title": "Statut technique", "value": status.upper()}, "_(En attente de validation par le responsable applicatif)_", ], color=color, ) return _post(webhook_url, payload) def send_planning_reminder(webhook_url: str, hostname: str, application: str, jour: str, heure: str, intervenant: str) -> Dict[str, Any]: """Rappel de planning (envoyable du jeudi/vendredi précédent jusqu'au jour J).""" payload = _adaptive_card( title=f"🗓 Rappel planning patching — {hostname}", body_lines=[ {"title": "Serveur", "value": hostname}, {"title": "Application", "value": application or "—"}, {"title": "Date prévue", "value": jour or "—"}, {"title": "Heure prévue", "value": heure or "—"}, {"title": "Intervenant", "value": intervenant or "—"}, "_Merci de confirmer la disponibilité applicative._", ], color="default", ) return _post(webhook_url, payload)