members.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. """Registre des membres vus par le bot (noms, anciens pseudos, réglages de la mémoire), dans un fichier JSON."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import os
  7. from datetime import datetime
  8. from pathlib import Path
  9. logger = logging.getLogger("chatbot.members")
  10. FILE_VERSION = 1
  11. # Noms Discord d'un membre : nom d'utilisateur (unique), nom d'affichage, pseudo sur le serveur.
  12. NAME_KEYS = ("username", "global_name", "nickname")
  13. # Anciens pseudos gardés par membre, du plus ancien au plus récent.
  14. MAX_FORMER_NAMES = 5
  15. class MemberRegistry:
  16. """Garde, pour chaque membre vu (auteur d'un message lu par le bot, ou membre mentionné), ses noms
  17. actuels et ses anciens pseudos, s'il a été prévenu de la mémoire et s'il l'a désactivée.
  18. Le fichier n'est réécrit que si quelque chose change : pas d'écriture à chaque message."""
  19. def __init__(self, file_path: Path):
  20. self.file_path = file_path
  21. self._data: dict[str, dict] = self._load()
  22. self._save_lock = asyncio.Lock()
  23. # ------------------------------------------------------------------ accès
  24. def get(self, member_id: int | str) -> dict | None:
  25. return self._data.get(str(member_id))
  26. def members(self) -> dict[str, dict]:
  27. """Tous les membres connus, par identifiant."""
  28. return self._data
  29. def memory_enabled(self, member_id: int | str) -> bool:
  30. """La mémoire est activée par défaut, y compris pour un membre encore inconnu."""
  31. entry = self.get(member_id)
  32. return entry is None or entry["memory"]
  33. async def see(self, member_id: int, username: str, global_name: str | None,
  34. nickname: str | None = None, in_guild: bool = True) -> None:
  35. """Enregistre un membre ou met ses noms à jour ; un nom remplacé rejoint les anciens pseudos.
  36. in_guild=False (message privé) : le pseudo sur le serveur n'est pas connu, il reste inchangé."""
  37. names = {"username": username, "global_name": global_name or None}
  38. if in_guild:
  39. names["nickname"] = nickname or None
  40. key = str(member_id)
  41. entry = self._data.get(key)
  42. if entry is None:
  43. self._data[key] = {
  44. **{name_key: None for name_key in NAME_KEYS}, **names,
  45. "former_names": [], "notified": False, "memory": True,
  46. "first_seen": datetime.now().strftime("%Y-%m-%d"),
  47. }
  48. logger.info("Nouveau membre dans le registre : %s (%s)", display_name(self._data[key]), key)
  49. await self._save()
  50. return
  51. old_names = [entry[name_key] for name_key in NAME_KEYS if entry[name_key]]
  52. changed = False
  53. for name_key, value in names.items():
  54. if entry[name_key] != value:
  55. entry[name_key] = value
  56. changed = True
  57. if not changed:
  58. return
  59. current = {entry[name_key] for name_key in NAME_KEYS if entry[name_key]}
  60. former = [name for name in entry["former_names"] if name not in current]
  61. for name in old_names:
  62. if name not in current and name not in former:
  63. former.append(name)
  64. entry["former_names"] = former[-MAX_FORMER_NAMES:]
  65. logger.info("Noms mis à jour dans le registre : %s (%s)", display_name(entry), key)
  66. await self._save()
  67. async def set_notified(self, member_id: int | str) -> None:
  68. """Le membre a été prévenu de la mémoire : il ne le sera plus."""
  69. if (entry := self.get(member_id)) is not None and not entry["notified"]:
  70. entry["notified"] = True
  71. await self._save()
  72. async def set_memory(self, member_id: int | str, enabled: bool) -> None:
  73. """Active ou désactive la mémoire pour ce membre."""
  74. if (entry := self.get(member_id)) is not None and entry["memory"] != enabled:
  75. entry["memory"] = enabled
  76. logger.info("Mémoire %s pour %s (%s)", "activée" if enabled else "désactivée", display_name(entry), member_id)
  77. await self._save()
  78. # ------------------------------------------------------------ internes
  79. def _load(self) -> dict[str, dict]:
  80. if not self.file_path.exists():
  81. return {}
  82. try:
  83. raw = json.loads(self.file_path.read_text(encoding="utf-8"))
  84. if not isinstance(raw, dict) or not isinstance(raw.get("members"), dict):
  85. raise ValueError("« members » absent ou n'est pas un dictionnaire")
  86. except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
  87. self._set_aside(exc)
  88. return {}
  89. return {key: entry for key, value in raw["members"].items() if (entry := self._sanitize(value))}
  90. @staticmethod
  91. def _sanitize(value) -> dict | None:
  92. """Une entrée du fichier, complétée si besoin, ou None si elle est illisible."""
  93. if not isinstance(value, dict) or not isinstance(value.get("username"), str):
  94. return None
  95. entry = {name_key: value.get(name_key) if isinstance(value.get(name_key), str) else None for name_key in NAME_KEYS}
  96. former = value.get("former_names")
  97. entry["former_names"] = [n for n in former if isinstance(n, str)] if isinstance(former, list) else []
  98. entry["notified"] = value.get("notified") is True
  99. entry["memory"] = value.get("memory") is not False
  100. entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
  101. return entry
  102. def _set_aside(self, exc: Exception) -> None:
  103. """Met de côté un fichier illisible, sous un nom daté, comme pour l'historique."""
  104. stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  105. backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
  106. logger.error("Registre des membres illisible (%s). Copie de secours : %s", exc, backup)
  107. try:
  108. os.replace(self.file_path, backup)
  109. except OSError as err:
  110. logger.error("Copie de secours impossible : %s", err)
  111. async def _save(self) -> None:
  112. payload = {"version": FILE_VERSION, "members": self._data}
  113. text = json.dumps(payload, ensure_ascii=False, indent=2)
  114. async with self._save_lock:
  115. try:
  116. await asyncio.to_thread(self._write_atomic, text)
  117. except OSError as exc:
  118. logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
  119. def _write_atomic(self, text: str) -> None:
  120. # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
  121. self.file_path.parent.mkdir(parents=True, exist_ok=True)
  122. tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
  123. tmp.write_text(text, encoding="utf-8")
  124. os.replace(tmp, self.file_path)
  125. def display_name(entry: dict) -> str:
  126. """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""
  127. return entry["nickname"] or entry["global_name"] or entry["username"]