1
0

presence.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. """Statut Discord du bot : l'anime qu'il regarde, la musique qu'il écoute, le jeu auquel il joue…
  2. Chaque statut est tiré au sort selon PRESENCE_WEIGHTS, avec sa propre durée (quelques épisodes,
  3. quelques morceaux, une session de jeu…) ; à la fin, un nouveau statut est tiré. Pendant la nuit
  4. (PRESENCE_NIGHT), le bot est absent.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import logging
  9. import random
  10. from collections import deque
  11. from dataclasses import dataclass
  12. from datetime import datetime, timedelta, timezone
  13. from enum import Enum
  14. from typing import TYPE_CHECKING
  15. import discord
  16. from .presence_sources import PresenceSources, Track
  17. if TYPE_CHECKING:
  18. from discord.ext import commands
  19. from .claude_client import ClaudeClient
  20. from .config import Settings
  21. logger = logging.getLogger("chatbot.presence")
  22. UTC = timezone.utc # datetime.UTC n'existe qu'à partir de Python 3.11
  23. SOURCES_REFRESH_INTERVAL = 24 * 3600 # secondes
  24. CUSTOM_STATUS_MAX_LENGTH = 128 # limite de Discord
  25. RECENT_CUSTOM_STATUSES = 4 # statuts personnalisés récents, à ne pas répéter
  26. # Durée de chaque statut, tirée au sort entre ces bornes.
  27. ANIME_EPISODES = (1, 5)
  28. MUSIC_TRACKS = (6, 20)
  29. DEFAULT_ANIME_EPISODES = 12 # épisodes disponibles quand AniList ne le précise pas
  30. MINUTES = { # en minutes
  31. "jeu": (45, 120),
  32. "en_ligne": (15, 45),
  33. "pensee": (30, 90),
  34. "absent": (10, 60),
  35. "ne_pas_deranger": (30, 90),
  36. }
  37. WEEKDAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
  38. class Kind(Enum):
  39. # Les valeurs sont les noms utilisés dans PRESENCE_WEIGHTS.
  40. ANIME = "anime"
  41. MUSIC = "musique"
  42. THOUGHT = "pensee"
  43. GAME = "jeu"
  44. ONLINE = "en_ligne"
  45. DND = "ne_pas_deranger"
  46. IDLE = "absent"
  47. NIGHT = "nuit" # absent automatiquement pendant PRESENCE_NIGHT, jamais tiré au sort
  48. @dataclass(frozen=True)
  49. class Status:
  50. kind: Kind
  51. title: str | None = None # anime, jeu, pensée, ou raison d'une absence
  52. track: Track | None = None # musique en cours
  53. episode: int | None = None # épisode en cours (None pour un film)
  54. def activity(self) -> discord.BaseActivity | None:
  55. if self.kind is Kind.ANIME:
  56. state = f"Épisode {self.episode}" if self.episode else None
  57. return discord.Activity(type=discord.ActivityType.watching, name=self.title, state=state)
  58. if self.kind is Kind.MUSIC:
  59. return discord.Activity(type=discord.ActivityType.listening,
  60. name=f"{self.track.title} de {self.track.artist}")
  61. if self.kind is Kind.GAME:
  62. return discord.Game(name=self.title)
  63. if self.title: # pensée, ou raison d'une absence
  64. return discord.CustomActivity(name=self.title)
  65. return None
  66. def discord_status(self) -> discord.Status:
  67. if self.kind is Kind.DND:
  68. return discord.Status.dnd
  69. if self.kind in (Kind.IDLE, Kind.NIGHT):
  70. return discord.Status.idle
  71. return discord.Status.online
  72. def describe(self) -> str | None:
  73. """Ce que fait le bot, vu de lui-même (None : en ligne, sans activité)."""
  74. reason = f" (statut affiché : « {self.title} »)" if self.title else ""
  75. if self.kind is Kind.ANIME:
  76. episode = f"l'épisode {self.episode} de " if self.episode else ""
  77. return f"tu regardes {episode}l'anime « {self.title} »"
  78. if self.kind is Kind.MUSIC:
  79. return f"tu écoutes « {self.track.title} » de {self.track.artist}"
  80. if self.kind is Kind.GAME:
  81. return f"tu joues à « {self.title} » sur Steam"
  82. if self.kind is Kind.THOUGHT:
  83. return f"tu affiches le statut personnalisé « {self.title} »"
  84. if self.kind is Kind.DND:
  85. return f"tu es en « Ne pas déranger »{reason}"
  86. if self.kind is Kind.IDLE:
  87. return f"tu es absente{reason}"
  88. if self.kind is Kind.NIGHT:
  89. return "tu es absente pour la nuit"
  90. return None
  91. # Un statut et sa durée en secondes. Un anime ou une musique en enchaîne plusieurs (un par épisode
  92. # ou par morceau) : c'est le « programme » du statut.
  93. Step = tuple[Status, float]
  94. def format_duration(seconds: float) -> str:
  95. minutes = max(1, round(seconds / 60))
  96. if minutes < 60:
  97. return f"{minutes} min"
  98. return f"{minutes // 60} h {minutes % 60:02d}" if minutes % 60 else f"{minutes // 60} h"
  99. class PresenceManager:
  100. def __init__(self, bot: commands.Bot, claude: ClaudeClient, settings: Settings):
  101. self.bot = bot
  102. self.claude = claude
  103. self.settings = settings
  104. self.weights = {Kind(name): weight for name, weight in settings.presence_weights.items()}
  105. self.sources = PresenceSources(
  106. settings.history_file.with_name("presence_sources.json"),
  107. settings.deezer_playlists, settings.steam_api_key, settings.steam_id,
  108. )
  109. self.status = Status(Kind.ONLINE)
  110. self.program: list[Step] = [] # étapes restantes du statut en cours
  111. self.step_end = datetime.min.replace(tzinfo=UTC) # fin de l'étape en cours (UTC)
  112. self.status_end: datetime | None = None # fin du statut en cours, toutes étapes comprises
  113. self.recent_custom: deque[str] = deque(maxlen=RECENT_CUSTOM_STATUSES)
  114. self._tasks: list[asyncio.Task] = []
  115. def start(self) -> None:
  116. if not self.sources.steam_enabled:
  117. logger.info("STEAM_API_KEY ou STEAM_ID absent : pas de statut « joue à ».")
  118. self._tasks.append(asyncio.create_task(self._run()))
  119. def stop(self) -> None:
  120. for task in self._tasks:
  121. task.cancel()
  122. def prompt_note(self) -> str | None:
  123. """Ligne ajoutée au début du message envoyé à Claude."""
  124. description = self.status.describe()
  125. if not description:
  126. return None
  127. if self.status_end and self.status.kind is not Kind.NIGHT:
  128. now = datetime.now(UTC)
  129. speed = self.settings.presence_speed
  130. total = format_duration((self.status_end - now).total_seconds() * speed)
  131. if not self.program:
  132. if self.status.kind is Kind.ANIME and self.status.episode:
  133. description += " (le dernier épisode que tu comptes regarder)"
  134. elif self.status.kind is Kind.MUSIC:
  135. description += " (le dernier morceau que tu comptes écouter)"
  136. description += f", encore pendant environ {total}"
  137. else:
  138. # Anime ou musique : l'épisode (ou le morceau) en cours n'est qu'une partie du programme.
  139. current = format_duration((self.step_end - now).total_seconds() * speed)
  140. if self.status.kind is Kind.ANIME:
  141. description += (f" (fin de l'épisode dans environ {current}) ; tu comptes regarder "
  142. f"jusqu'à l'épisode {self.program[-1][0].episode}, encore environ {total} en tout")
  143. else:
  144. description += (f" (fin du morceau dans environ {current}) ; encore {len(self.program)} "
  145. f"morceau(x) après celui-ci, environ {total} de musique en tout")
  146. return f"[ton statut Discord : {description}]"
  147. async def apply(self) -> None:
  148. """Envoie le statut à Discord (aussi rappelé après une reconnexion, qui l'efface)."""
  149. try:
  150. await self.bot.change_presence(activity=self.status.activity(), status=self.status.discord_status())
  151. except Exception as exc: # déconnecté : on_ready le renverra
  152. logger.warning("Statut Discord non appliqué : %s", exc)
  153. # ------------------------------------------------------------ boucles
  154. async def _run(self) -> None:
  155. self.sources.load_cache()
  156. await self.sources.refresh()
  157. self._tasks.append(asyncio.create_task(self._refresh_loop()))
  158. await self.bot.wait_until_ready()
  159. while True:
  160. try:
  161. wake = await self._step()
  162. except Exception:
  163. logger.exception("Erreur inattendue dans la gestion du statut")
  164. wake = datetime.now(UTC) + timedelta(minutes=1)
  165. await asyncio.sleep(max(1.0, (wake - datetime.now(UTC)).total_seconds()))
  166. async def _refresh_loop(self) -> None:
  167. while True:
  168. await asyncio.sleep(SOURCES_REFRESH_INTERVAL)
  169. try:
  170. await self.sources.refresh()
  171. except Exception:
  172. logger.exception("Erreur inattendue pendant la mise à jour des listes de statuts")
  173. async def _step(self) -> datetime:
  174. """Passe à l'étape suivante quand la précédente est finie ; renvoie l'heure du prochain réveil."""
  175. night = self.settings.presence_night
  176. now = datetime.now(UTC)
  177. local = now.astimezone(self.settings.timezone)
  178. if self.is_night(local):
  179. if self.status.kind is not Kind.NIGHT:
  180. # Coupure nette : le statut en cours s'arrête, un nouveau sera tiré au réveil.
  181. self.program = []
  182. self.status_end = None
  183. await self.set_status(Status(Kind.NIGHT))
  184. self.step_end = self.next_hour(local, night[1])
  185. return self.step_end
  186. if now >= self.step_end:
  187. if not self.program:
  188. self.program = await self.draw()
  189. total = sum(seconds for _, seconds in self.program)
  190. self.status_end = now + timedelta(seconds=total)
  191. logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
  192. status, seconds = self.program.pop(0)
  193. self.step_end = now + timedelta(seconds=seconds)
  194. await self.set_status(status)
  195. if night:
  196. return min(self.step_end, self.next_hour(local, night[0]))
  197. return self.step_end
  198. # ------------------------------------------------------------ tirage
  199. async def draw(self) -> list[Step]:
  200. """Tire un nouveau statut et son programme (durées divisées par PRESENCE_SPEED)."""
  201. available = {kind: weight for kind, weight in self.weights.items() if weight > 0 and self.has_source(kind)}
  202. # Jamais deux fois le même statut à la suite, sauf s'il n'y a pas d'autre choix.
  203. if len(available) > 1:
  204. available.pop(self.status.kind, None)
  205. kind = random.choices(list(available), weights=list(available.values()))[0] if available else Kind.ONLINE
  206. speed = self.settings.presence_speed
  207. if kind is Kind.ANIME:
  208. anime = self.pick(self.sources.anime, lambda a: a.title == self.status.title)
  209. if anime.episodes == 1: # film
  210. return [(Status(kind, title=anime.title), anime.duration * 60 / speed)]
  211. available_episodes = anime.episodes or DEFAULT_ANIME_EPISODES
  212. count = min(random.randint(*ANIME_EPISODES), available_episodes)
  213. first = random.randint(1, available_episodes - count + 1)
  214. return [(Status(kind, title=anime.title, episode=n), anime.duration * 60 / speed)
  215. for n in range(first, first + count)]
  216. if kind is Kind.MUSIC:
  217. count = min(random.randint(*MUSIC_TRACKS), len(self.sources.music))
  218. tracks = random.sample(self.sources.music, count)
  219. return [(Status(kind, track=t), t.duration / speed) for t in tracks]
  220. seconds = random.randint(*MINUTES[kind.value]) * 60 if kind.value in MINUTES else 0
  221. if kind is Kind.GAME:
  222. title = self.pick(self.sources.games, lambda g: g == self.status.title)
  223. return [(Status(kind, title=title), seconds / speed)]
  224. if kind in (Kind.THOUGHT, Kind.IDLE, Kind.DND):
  225. text = await self.write_custom_status(kind, seconds)
  226. if kind is Kind.THOUGHT and not text:
  227. kind = Kind.ONLINE # pensée impossible à écrire : en ligne, sans activité
  228. return [(Status(kind, title=text), seconds / speed)]
  229. return [(Status(kind), seconds / speed)]
  230. async def write_custom_status(self, kind: Kind, seconds: int) -> str | None:
  231. local = datetime.now(self.settings.timezone)
  232. now = f"{WEEKDAYS[local.weekday()]} {local.strftime('%d/%m/%Y, %H:%M')}"
  233. text = await self.claude.write_custom_status(
  234. kind.value, now, format_duration(seconds), list(self.recent_custom), CUSTOM_STATUS_MAX_LENGTH,
  235. )
  236. if text:
  237. self.recent_custom.append(text)
  238. return text
  239. def has_source(self, kind: Kind) -> bool:
  240. if kind is Kind.ANIME:
  241. return bool(self.sources.anime)
  242. if kind is Kind.MUSIC:
  243. return bool(self.sources.music)
  244. if kind is Kind.GAME:
  245. return bool(self.sources.games)
  246. return True
  247. @staticmethod
  248. def pick(items: list, is_current):
  249. """Élément au hasard, différent de l'actuel quand c'est possible."""
  250. return random.choice([item for item in items if not is_current(item)] or items)
  251. async def set_status(self, status: Status) -> None:
  252. self.status = status
  253. logger.info("Statut Discord : %s", status.describe() or "en ligne, sans activité")
  254. await self.apply()
  255. # ------------------------------------------------------------ nuit
  256. def is_night(self, local: datetime) -> bool:
  257. if not self.settings.presence_night:
  258. return False
  259. start, end = self.settings.presence_night
  260. if start < end:
  261. return start <= local.hour < end
  262. return local.hour >= start or local.hour < end # nuit à cheval sur minuit (ex. 23-6)
  263. @staticmethod
  264. def next_hour(local: datetime, hour: int) -> datetime:
  265. """Prochain passage à hour:00, en UTC."""
  266. candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0)
  267. if candidate <= local:
  268. candidate += timedelta(days=1)
  269. return candidate.astimezone(UTC)