| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- """Registre des membres vus par le bot (noms, anciens pseudos, réglages de la mémoire), dans un fichier JSON."""
- from __future__ import annotations
- import asyncio
- import json
- import logging
- from datetime import datetime
- from pathlib import Path
- from .files import write_atomic
- logger = logging.getLogger("chatbot.members")
- FILE_VERSION = 1
- # Noms Discord d'un membre : nom d'utilisateur (unique), nom d'affichage, pseudo sur le serveur.
- NAME_KEYS = ("username", "global_name", "nickname")
- # Anciens pseudos gardés par membre, du plus ancien au plus récent.
- MAX_FORMER_NAMES = 5
- class MemberRegistry:
- """Garde, pour chaque membre vu (auteur d'un message lu par le bot, ou membre mentionné), ses noms
- actuels et ses anciens pseudos, s'il a été prévenu de la mémoire et s'il l'a désactivée.
- Le fichier n'est réécrit que si quelque chose change : pas d'écriture à chaque message."""
- def __init__(self, file_path: Path):
- self.file_path = file_path
- # Raison pour laquelle le fichier est illisible, ou None. Repartir d'un registre vide oublierait
- # qui a désactivé la mémoire : la mémoire est alors coupée, et le fichier n'est jamais réécrit.
- self.broken: str | None = None
- self._data: dict[str, dict] = self._load()
- self._save_lock = asyncio.Lock()
- # ------------------------------------------------------------------ accès
- def get(self, member_id: int | str) -> dict | None:
- return self._data.get(str(member_id))
- def members(self) -> dict[str, dict]:
- """Tous les membres connus, par identifiant."""
- return self._data
- def memory_enabled(self, member_id: int | str) -> bool:
- """La mémoire est activée par défaut, y compris pour un membre encore inconnu."""
- entry = self.get(member_id)
- return entry is None or entry["memory"]
- async def see(self, member_id: int, username: str, global_name: str | None,
- nickname: str | None = None, in_guild: bool = True) -> None:
- """Enregistre un membre ou met ses noms à jour ; un nom remplacé rejoint les anciens pseudos.
- in_guild=False (message privé) : le pseudo sur le serveur n'est pas connu, il reste inchangé."""
- names = {"username": username, "global_name": global_name or None}
- if in_guild:
- names["nickname"] = nickname or None
- key = str(member_id)
- entry = self._data.get(key)
- if entry is None:
- self._data[key] = {
- **{name_key: None for name_key in NAME_KEYS}, **names,
- "former_names": [], "notified": False, "memory": True,
- "first_seen": datetime.now().strftime("%Y-%m-%d"),
- }
- logger.info("Nouveau membre dans le registre : %s (%s)", display_name(self._data[key]), key)
- await self._save()
- return
- old_names = [entry[name_key] for name_key in NAME_KEYS if entry[name_key]]
- changed = False
- for name_key, value in names.items():
- if entry[name_key] != value:
- entry[name_key] = value
- changed = True
- if not changed:
- return
- current = {entry[name_key] for name_key in NAME_KEYS if entry[name_key]}
- former = [name for name in entry["former_names"] if name not in current]
- for name in old_names:
- if name not in current and name not in former:
- former.append(name)
- entry["former_names"] = former[-MAX_FORMER_NAMES:]
- logger.info("Noms mis à jour dans le registre : %s (%s)", display_name(entry), key)
- await self._save()
- async def set_notified(self, member_id: int | str) -> None:
- """Le membre a été prévenu de la mémoire : il ne le sera plus."""
- if (entry := self.get(member_id)) is not None and not entry["notified"]:
- entry["notified"] = True
- await self._save()
- async def set_memory(self, member_id: int | str, enabled: bool) -> None:
- """Active ou désactive la mémoire pour ce membre."""
- if (entry := self.get(member_id)) is not None and entry["memory"] != enabled:
- entry["memory"] = enabled
- logger.info("Mémoire %s pour %s (%s)", "activée" if enabled else "désactivée", display_name(entry), member_id)
- await self._save()
- # ------------------------------------------------------------ internes
- def _load(self) -> dict[str, dict]:
- if not self.file_path.exists():
- return {}
- try:
- # -sig : BOM laissé par certains éditeurs Windows (Bloc-notes) après une correction à la main.
- raw = json.loads(self.file_path.read_text(encoding="utf-8-sig"))
- if not isinstance(raw, dict) or not isinstance(raw.get("members"), dict):
- raise ValueError("« members » absent ou n'est pas un dictionnaire")
- return {key: self._sanitize(key, value) for key, value in raw["members"].items()}
- except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
- self.broken = str(exc)
- logger.error("Registre des membres illisible (%s) : mémoire des membres coupée jusqu'à sa "
- "réparation. Corrige %s puis redémarre le bot ; le supprimer repartirait de zéro, "
- "en réactivant la mémoire de ceux qui l'avaient désactivée.", exc, self.file_path)
- return {}
- @staticmethod
- def _sanitize(key: str, value) -> dict:
- """Une entrée du fichier, complétée si besoin. ValueError si elle est illisible, ou si on ne sait
- pas si la mémoire est activée : la croire activée reviendrait à noter sur qui l'a désactivée."""
- if not isinstance(value, dict) or not isinstance(value.get("username"), str):
- raise ValueError(f"membre {key} : entrée illisible")
- if not isinstance(value.get("memory"), bool):
- raise ValueError(f"membre {key} : « memory » doit valoir true ou false")
- entry = {name_key: value.get(name_key) if isinstance(value.get(name_key), str) else None for name_key in NAME_KEYS}
- former = value.get("former_names")
- entry["former_names"] = [n for n in former if isinstance(n, str)] if isinstance(former, list) else []
- entry["notified"] = value.get("notified") is True
- entry["memory"] = value["memory"]
- entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
- return entry
- async def _save(self) -> None:
- if self.broken:
- return # le fichier illisible attend sa réparation : l'écraser perdrait les désactivations
- payload = {"version": FILE_VERSION, "members": self._data}
- text = json.dumps(payload, ensure_ascii=False, indent=2)
- async with self._save_lock:
- try:
- await asyncio.to_thread(write_atomic, self.file_path, text)
- except OSError as exc:
- logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
- def display_name(entry: dict) -> str:
- """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""
- return entry["nickname"] or entry["global_name"] or entry["username"]
- def with_username(name: str, username: str) -> str:
- """« Johnounet (johnk) » : le nom affiché, libre et non unique, suivi du nom d'utilisateur, unique,
- pour que personne ne puisse se faire passer pour un autre en prenant son pseudo. Seul s'il est
- identique au nom affiché (« Penta » et « penta »)."""
- return name if name.casefold() == username.casefold() else f"{name} ({username})"
|