1
0

members.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. from datetime import datetime
  7. from pathlib import Path
  8. from .files import write_atomic
  9. logger = logging.getLogger("chatbot.members")
  10. # Version 2 : mémoire à activer soi-même avec /memoire (en version 1, elle l'était par défaut, après un
  11. # message de bienvenue : voir _migrate).
  12. FILE_VERSION = 2
  13. # Noms Discord d'un membre : nom d'utilisateur (unique), nom d'affichage, pseudo sur le serveur.
  14. NAME_KEYS = ("username", "global_name", "nickname")
  15. # Anciens pseudos gardés par membre, du plus ancien au plus récent.
  16. MAX_FORMER_NAMES = 5
  17. class MemberRegistry:
  18. """Garde, pour chaque membre vu (auteur d'un message lu par le bot, ou membre mentionné), ses noms
  19. actuels et ses anciens pseudos, et s'il a activé la mémoire (désactivée par défaut).
  20. Le fichier n'est réécrit que si quelque chose change : pas d'écriture à chaque message."""
  21. def __init__(self, file_path: Path):
  22. self.file_path = file_path
  23. # Raison pour laquelle le fichier est illisible, ou None. Repartir d'un registre vide oublierait
  24. # qui a désactivé la mémoire : la mémoire est alors coupée, et le fichier n'est jamais réécrit.
  25. self.broken: str | None = None
  26. self._data: dict[str, dict] = self._load()
  27. self._save_lock = asyncio.Lock()
  28. # Dernière sauvegarde ratée (disque plein, droits) : le fichier est en retard sur _data. La
  29. # suivante, quelle qu'elle soit, réécrit tout et rattrape ce retard.
  30. self._unsaved = False
  31. # ------------------------------------------------------------------ accès
  32. def get(self, member_id: int | str) -> dict | None:
  33. return self._data.get(str(member_id))
  34. def members(self) -> dict[str, dict]:
  35. """Tous les membres connus, par identifiant."""
  36. return self._data
  37. def memory_enabled(self, member_id: int | str) -> bool:
  38. """Le membre a activé la mémoire avec /memoire : sans cela, rien n'est noté sur lui."""
  39. entry = self.get(member_id)
  40. return entry is not None and entry["memory"]
  41. async def see(self, member_id: int, username: str, global_name: str | None,
  42. nickname: str | None = None, in_guild: bool = True) -> None:
  43. """Enregistre un membre ou met ses noms à jour ; un nom remplacé rejoint les anciens pseudos.
  44. in_guild=False (message privé) : le pseudo sur le serveur n'est pas connu, il reste inchangé."""
  45. names = {"username": username, "global_name": global_name or None}
  46. if in_guild:
  47. names["nickname"] = nickname or None
  48. key = str(member_id)
  49. entry = self._data.get(key)
  50. if entry is None:
  51. self._data[key] = {
  52. **{name_key: None for name_key in NAME_KEYS}, **names,
  53. "former_names": [], "memory": False,
  54. "first_seen": datetime.now().strftime("%Y-%m-%d"),
  55. }
  56. logger.info("Nouveau membre dans le registre : %s (%s)", display_name(self._data[key]), key)
  57. await self._save()
  58. return
  59. old_names = [entry[name_key] for name_key in NAME_KEYS if entry[name_key]]
  60. changed = False
  61. for name_key, value in names.items():
  62. if entry[name_key] != value:
  63. entry[name_key] = value
  64. changed = True
  65. if not changed:
  66. return
  67. current = {entry[name_key] for name_key in NAME_KEYS if entry[name_key]}
  68. former = [name for name in entry["former_names"] if name not in current]
  69. for name in old_names:
  70. if name not in current and name not in former:
  71. former.append(name)
  72. entry["former_names"] = former[-MAX_FORMER_NAMES:]
  73. logger.info("Noms mis à jour dans le registre : %s (%s)", display_name(entry), key)
  74. await self._save()
  75. async def set_memory(self, member_id: int | str, enabled: bool) -> bool:
  76. """Active ou désactive la mémoire pour ce membre. Renvoie False si le réglage n'a pas pu être
  77. enregistré : il s'applique quand même, mais serait perdu à un redémarrage avant la prochaine
  78. sauvegarde réussie."""
  79. if (entry := self.get(member_id)) is None:
  80. return True
  81. if entry["memory"] != enabled:
  82. entry["memory"] = enabled
  83. logger.info("Mémoire %s pour %s (%s)", "activée" if enabled else "désactivée", display_name(entry), member_id)
  84. elif not self._unsaved:
  85. return True
  86. return await self._save()
  87. # ------------------------------------------------------------ internes
  88. def _load(self) -> dict[str, dict]:
  89. if not self.file_path.exists():
  90. return {}
  91. try:
  92. # -sig : BOM laissé par certains éditeurs Windows (Bloc-notes) après une correction à la main.
  93. raw = json.loads(self.file_path.read_text(encoding="utf-8-sig"))
  94. if not isinstance(raw, dict) or not isinstance(raw.get("members"), dict):
  95. raise ValueError("« members » absent ou n'est pas un dictionnaire")
  96. data = {key: self._sanitize(key, value) for key, value in raw["members"].items()}
  97. except (ValueError, OSError) as exc: # JSON invalide, mauvais encodage ou structure inattendue
  98. self.broken = str(exc)
  99. logger.error("Registre des membres illisible (%s) : mémoire des membres coupée jusqu'à sa "
  100. "réparation. Corrige %s puis redémarre le bot ; le supprimer repartirait de zéro, "
  101. "en désactivant la mémoire de tous ceux qui l'avaient activée.", exc, self.file_path)
  102. return {}
  103. if raw.get("version", 1) < 2:
  104. self._migrate(raw["members"], data)
  105. return data
  106. def _migrate(self, raw: dict, data: dict[str, dict]) -> None:
  107. """Passage à la mémoire à activer soi-même : elle ne reste activée que pour les membres qui avaient
  108. reçu le message de bienvenue, ne l'avaient pas désactivée et ont une fiche non vide (rangée à côté
  109. du registre, dans membres/). Les autres l'activeront avec /memoire s'ils le souhaitent."""
  110. kept = 0
  111. for key, entry in data.items():
  112. path = self.file_path.parent / "membres" / f"{key}.md"
  113. try:
  114. has_file = path.is_file() and path.stat().st_size > 0
  115. except OSError:
  116. has_file = False
  117. entry["memory"] = entry["memory"] and raw[key].get("notified") is True and has_file
  118. kept += entry["memory"]
  119. logger.info("Registre des membres : la mémoire est désormais à activer avec /memoire. Gardée "
  120. "activée pour %d membre(s) qui ont une fiche, désactivée pour %d.", kept, len(data) - kept)
  121. try:
  122. write_atomic(self.file_path, json.dumps({"version": FILE_VERSION, "members": data},
  123. ensure_ascii=False, indent=2))
  124. except OSError as exc: # refait au prochain démarrage, ou à la prochaine sauvegarde réussie
  125. logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
  126. @staticmethod
  127. def _sanitize(key: str, value) -> dict:
  128. """Une entrée du fichier, complétée si besoin. ValueError si elle est illisible, ou si on ne sait
  129. pas si la mémoire est activée : mieux vaut que l'administrateur corrige le fichier."""
  130. if not isinstance(value, dict) or not isinstance(value.get("username"), str):
  131. raise ValueError(f"membre {key} : entrée illisible")
  132. if not isinstance(value.get("memory"), bool):
  133. raise ValueError(f"membre {key} : « memory » doit valoir true ou false")
  134. entry = {name_key: value.get(name_key) if isinstance(value.get(name_key), str) else None for name_key in NAME_KEYS}
  135. former = value.get("former_names")
  136. entry["former_names"] = [n for n in former if isinstance(n, str)] if isinstance(former, list) else []
  137. entry["memory"] = value["memory"]
  138. entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
  139. return entry
  140. async def _save(self) -> bool:
  141. """Écrit le registre ; renvoie False s'il n'a pas pu l'être."""
  142. if self.broken:
  143. return False # le fichier illisible attend sa réparation : l'écraser perdrait les désactivations
  144. payload = {"version": FILE_VERSION, "members": self._data}
  145. text = json.dumps(payload, ensure_ascii=False, indent=2)
  146. async with self._save_lock:
  147. try:
  148. await asyncio.to_thread(write_atomic, self.file_path, text)
  149. except OSError as exc:
  150. logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
  151. self._unsaved = True
  152. return False
  153. self._unsaved = False
  154. return True
  155. def display_name(entry: dict) -> str:
  156. """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""
  157. return entry["nickname"] or entry["global_name"] or entry["username"]
  158. def with_username(name: str, username: str) -> str:
  159. """« Johnounet (johnk) » : le nom affiché, libre et non unique, suivi du nom d'utilisateur, unique,
  160. pour que personne ne puisse se faire passer pour un autre en prenant son pseudo. Seul s'il est
  161. identique au nom affiché (« Penta » et « penta »)."""
  162. return name if name.casefold() == username.casefold() else f"{name} ({username})"