1
0

members.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  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"]
  128. def with_username(name: str, username: str) -> str:
  129. """« Johnounet (johnk) » : le nom affiché, libre et non unique, suivi du nom d'utilisateur, unique,
  130. pour que personne ne puisse se faire passer pour un autre en prenant son pseudo. Seul s'il est
  131. identique au nom affiché (« Penta » et « penta »)."""
  132. return name if name.casefold() == username.casefold() else f"{name} ({username})"