presence.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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. Absent, le bot ne répond pas avant son retour ; en « Ne pas déranger », il répond au bout de
  6. quelques minutes (voir wait_turn). S'il était en pleine conversation dans son salon, il prévient
  7. d'abord qu'il s'absente (voir departure_note et ChatBot.on_status_change).
  8. Le statut en cours est gardé dans presence_state.json : après un redémarrage, le bot le reprend
  9. là où il en serait s'il ne s'était pas arrêté (voir restore).
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. import json
  14. import logging
  15. import os
  16. import random
  17. from collections import deque
  18. from dataclasses import asdict, dataclass
  19. from datetime import datetime, timedelta, timezone
  20. from enum import Enum
  21. from typing import TYPE_CHECKING
  22. import discord
  23. from .presence_sources import Game, PresenceSources, SourceError, Track
  24. if TYPE_CHECKING:
  25. from discord.ext import commands
  26. from .claude_client import ClaudeClient
  27. from .config import Settings
  28. logger = logging.getLogger("chatbot.presence")
  29. UTC = timezone.utc # datetime.UTC n'existe qu'à partir de Python 3.11
  30. SOURCES_REFRESH_INTERVAL = 24 * 3600 # secondes
  31. CUSTOM_STATUS_MAX_LENGTH = 128 # limite de Discord
  32. RECENT_CUSTOM_STATUSES = 4 # statuts personnalisés récents, à ne pas répéter
  33. # Variété : chances des derniers types de statut tirés, du plus récent au plus ancien (0 : exclu).
  34. # Les deux derniers ne peuvent pas revenir, le troisième revient deux fois moins souvent.
  35. RECENT_KIND_FACTORS = (0, 0, 0.5)
  36. GAME_ATTEMPTS = 5 # jeux essayés avant d'abandonner (fiche introuvable, jeu pour adultes…)
  37. DND_REPLY_MINUTES = (5, 15) # délai de réponse en « Ne pas déranger »
  38. # Durée de chaque statut, tirée au sort entre ces bornes.
  39. ANIME_EPISODES = (1, 5)
  40. MUSIC_TRACKS = (6, 20)
  41. DEFAULT_ANIME_EPISODES = 12 # épisodes disponibles quand AniList ne le précise pas
  42. MINUTES = { # en minutes
  43. "jeu": (45, 120),
  44. "en_ligne": (15, 45),
  45. "pensee": (30, 90),
  46. "absent": (10, 60),
  47. "ne_pas_deranger": (30, 90),
  48. }
  49. WEEKDAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
  50. class Kind(Enum):
  51. # Les valeurs sont les noms utilisés dans PRESENCE_WEIGHTS.
  52. ANIME = "anime"
  53. MUSIC = "musique"
  54. THOUGHT = "pensee"
  55. GAME = "jeu"
  56. ONLINE = "en_ligne"
  57. DND = "ne_pas_deranger"
  58. IDLE = "absent"
  59. NIGHT = "nuit" # absent automatiquement pendant PRESENCE_NIGHT, jamais tiré au sort
  60. @dataclass(frozen=True)
  61. class Status:
  62. kind: Kind
  63. title: str | None = None # anime, jeu, pensée, ou raison d'une absence
  64. track: Track | None = None # musique en cours
  65. episode: int | None = None # épisode en cours (None pour un film)
  66. details: str | None = None # présentation du jeu, pour Claude uniquement
  67. def activity(self) -> discord.BaseActivity | None:
  68. if self.kind is Kind.ANIME:
  69. state = f"Épisode {self.episode}" if self.episode else None
  70. return discord.Activity(type=discord.ActivityType.watching, name=self.title, state=state)
  71. if self.kind is Kind.MUSIC:
  72. return discord.Activity(type=discord.ActivityType.listening,
  73. name=f"{self.track.title} de {self.track.artist}")
  74. if self.kind is Kind.GAME:
  75. return discord.Game(name=self.title)
  76. if self.title: # pensée, ou raison d'une absence
  77. return discord.CustomActivity(name=self.title)
  78. return None
  79. def discord_status(self) -> discord.Status:
  80. if self.kind is Kind.DND:
  81. return discord.Status.dnd
  82. if self.kind in (Kind.IDLE, Kind.NIGHT):
  83. return discord.Status.idle
  84. return discord.Status.online
  85. @property
  86. def available(self) -> bool:
  87. return self.kind not in (Kind.DND, Kind.IDLE, Kind.NIGHT)
  88. def unavailability(self) -> str:
  89. """Pourquoi le bot ne répondait pas, vu de lui-même (statut absent ou Ne pas déranger)."""
  90. reason = f" (statut affiché : « {self.title} »)" if self.title else ""
  91. if self.kind is Kind.NIGHT:
  92. return "absente pour la nuit"
  93. if self.kind is Kind.DND:
  94. return f"en « Ne pas déranger »{reason}"
  95. return f"absente{reason}"
  96. def describe(self) -> str | None:
  97. """Ce que fait le bot, vu de lui-même (None : en ligne, sans activité)."""
  98. reason = f" (statut affiché : « {self.title} »)" if self.title else ""
  99. if self.kind is Kind.ANIME:
  100. episode = f"l'épisode {self.episode} de " if self.episode else ""
  101. return f"tu regardes {episode}l'anime « {self.title} »"
  102. if self.kind is Kind.MUSIC:
  103. return f"tu écoutes « {self.track.title} » de {self.track.artist}"
  104. if self.kind is Kind.GAME:
  105. details = f" (présentation du jeu sur Steam : {self.details})" if self.details else ""
  106. return f"tu joues à « {self.title} » sur Steam{details}"
  107. if self.kind is Kind.THOUGHT:
  108. return f"tu affiches le statut personnalisé « {self.title} »"
  109. if self.kind is Kind.DND:
  110. return f"tu es en « Ne pas déranger »{reason}"
  111. if self.kind is Kind.IDLE:
  112. return f"tu es absente{reason}"
  113. if self.kind is Kind.NIGHT:
  114. return "tu es absente pour la nuit"
  115. return None
  116. def label(self) -> str:
  117. """Le statut tel qu'il s'affiche sur Discord, pour les administrateurs (/skip)."""
  118. reason = f" (« {self.title} »)" if self.title else ""
  119. if self.kind is Kind.ANIME:
  120. episode = f"l'épisode {self.episode} de " if self.episode else ""
  121. return f"regarde {episode}« {self.title} »"
  122. if self.kind is Kind.MUSIC:
  123. return f"écoute « {self.track.title} » de {self.track.artist}"
  124. if self.kind is Kind.GAME:
  125. return f"joue à « {self.title} »"
  126. if self.kind is Kind.THOUGHT:
  127. return f"pensée du moment « {self.title} »"
  128. if self.kind is Kind.DND:
  129. return f"Ne pas déranger{reason}"
  130. if self.kind in (Kind.IDLE, Kind.NIGHT):
  131. return f"absent{reason}"
  132. return "en ligne, sans activité"
  133. def to_dict(self) -> dict:
  134. data = {"kind": self.kind.value, "title": self.title, "episode": self.episode, "details": self.details,
  135. "track": asdict(self.track) if self.track else None}
  136. return {key: value for key, value in data.items() if value is not None}
  137. @classmethod
  138. def from_dict(cls, data: dict) -> Status:
  139. track = Track(**data["track"]) if data.get("track") else None
  140. return cls(Kind(data["kind"]), title=data.get("title"), track=track,
  141. episode=data.get("episode"), details=data.get("details"))
  142. # Un statut et sa durée en secondes. Un anime ou une musique en enchaîne plusieurs (un par épisode
  143. # ou par morceau) : c'est le « programme » du statut.
  144. Step = tuple[Status, float]
  145. def format_duration(seconds: float) -> str:
  146. minutes = max(1, round(seconds / 60))
  147. if minutes < 60:
  148. return f"{minutes} min"
  149. return f"{minutes // 60} h {minutes % 60:02d}" if minutes % 60 else f"{minutes // 60} h"
  150. class PresenceManager:
  151. def __init__(self, bot: commands.Bot, claude: ClaudeClient, settings: Settings):
  152. self.bot = bot
  153. self.claude = claude
  154. self.settings = settings
  155. self.weights = {Kind(name): weight for name, weight in settings.presence_weights.items()}
  156. self.sources = PresenceSources(
  157. settings.history_file.with_name("presence_sources.json"),
  158. settings.deezer_playlists, settings.steam_api_key,
  159. )
  160. self.status = Status(Kind.ONLINE)
  161. self.program: list[Step] = [] # étapes restantes du statut en cours
  162. self.step_end = datetime.min.replace(tzinfo=UTC) # fin de l'étape en cours (UTC)
  163. self.status_end: datetime | None = None # fin du statut en cours, toutes étapes comprises
  164. self.recent_custom: deque[str] = deque(maxlen=RECENT_CUSTOM_STATUSES)
  165. self.recent_kinds: deque[Kind] = deque(maxlen=len(RECENT_KIND_FACTORS)) # derniers types tirés
  166. self.state_file = settings.history_file.with_name("presence_state.json")
  167. self._tasks: list[asyncio.Task] = []
  168. self._changed = asyncio.Event() # déclenché à chaque changement de statut (voir wait_turn)
  169. self._step_lock = asyncio.Lock() # /skip et la boucle ne changent pas le statut en même temps
  170. self._wake = asyncio.Event() # réveille la boucle après un /skip
  171. def start(self) -> None:
  172. if not self.sources.steam_enabled:
  173. logger.info("STEAM_API_KEY absent : pas de statut « joue à ».")
  174. if self.is_night(datetime.now(self.settings.timezone)):
  175. self.status = Status(Kind.NIGHT) # démarrage la nuit : absent dès le premier message
  176. else:
  177. self.restore()
  178. self._tasks.append(asyncio.create_task(self._run()))
  179. # ------------------------------------------------------------ sauvegarde
  180. def save(self) -> None:
  181. """Garde le statut en cours et la suite de son programme, pour les reprendre au redémarrage."""
  182. data = {
  183. "saved_at": datetime.now(UTC).isoformat(),
  184. "status": self.status.to_dict(),
  185. "step_end": self.step_end.isoformat(),
  186. "status_end": self.status_end.isoformat() if self.status_end else None,
  187. "program": [[status.to_dict(), seconds] for status, seconds in self.program],
  188. "recent_custom": list(self.recent_custom),
  189. "recent_kinds": [kind.value for kind in self.recent_kinds],
  190. }
  191. tmp = self.state_file.with_suffix(".json.tmp")
  192. try:
  193. tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  194. os.replace(tmp, self.state_file)
  195. except OSError as exc:
  196. logger.warning("Impossible d'écrire %s : %s", self.state_file, exc)
  197. def restore(self) -> None:
  198. """Reprend le statut d'avant le redémarrage, là où il en serait s'il avait continué
  199. pendant l'arrêt (un anime avance d'autant d'épisodes). Un statut terminé entre-temps,
  200. coupé par la nuit, ou désactivé depuis dans PRESENCE_WEIGHTS laisse place à un nouveau."""
  201. try:
  202. data = json.loads(self.state_file.read_text(encoding="utf-8"))
  203. self.recent_custom.extend(data.get("recent_custom", []))
  204. self.recent_kinds.extend(Kind(value) for value in data.get("recent_kinds", []))
  205. status =Status.from_dict(data["status"])
  206. program = [(Status.from_dict(step), float(seconds)) for step, seconds in data.get("program", [])]
  207. saved_at = datetime.fromisoformat(data["saved_at"])
  208. step_end = datetime.fromisoformat(data["step_end"])
  209. status_end = datetime.fromisoformat(data["status_end"]) if data.get("status_end") else None
  210. if any(date and date.tzinfo is None for date in (saved_at, step_end, status_end)):
  211. # Fichier modifié à la main : comparée à l'heure actuelle, une telle date lèverait TypeError.
  212. raise ValueError("date sans fuseau horaire")
  213. except FileNotFoundError:
  214. return
  215. except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc:
  216. logger.warning("%s illisible, ignoré : %s", self.state_file, exc)
  217. return
  218. now = datetime.now(UTC)
  219. if self.weights.get(status.kind, 0) <= 0:
  220. return # nuit, ou statut désactivé depuis dans PRESENCE_WEIGHTS
  221. if night := self.settings.presence_night:
  222. if self.next_hour(saved_at.astimezone(self.settings.timezone), night[0]) <= now:
  223. return # une nuit est passée depuis : le statut s'est arrêté avec elle
  224. while step_end <= now and program:
  225. status, seconds = program.pop(0)
  226. step_end += timedelta(seconds=seconds)
  227. if step_end <= now:
  228. return
  229. self.status, self.program, self.step_end, self.status_end = status, program, step_end, status_end
  230. logger.info("Statut d'avant le redémarrage repris : %s, encore %s.", status.describe() or "en ligne",
  231. format_duration((step_end - now).total_seconds() * self.settings.presence_speed))
  232. def stop(self) -> None:
  233. for task in self._tasks:
  234. task.cancel()
  235. def prompt_note(self) -> str | None:
  236. """Ligne ajoutée au début du message envoyé à Claude."""
  237. description = self.status.describe()
  238. if not description:
  239. return None
  240. if self.status_end and self.status.kind is not Kind.NIGHT:
  241. now = datetime.now(UTC)
  242. speed = self.settings.presence_speed
  243. total = format_duration((self.status_end - now).total_seconds() * speed)
  244. if not self.program:
  245. if self.status.kind is Kind.ANIME and self.status.episode:
  246. description += " (le dernier épisode que tu comptes regarder)"
  247. elif self.status.kind is Kind.MUSIC:
  248. description += " (le dernier morceau que tu comptes écouter)"
  249. description += f", encore pendant environ {total}"
  250. else:
  251. # Anime ou musique : l'épisode (ou le morceau) en cours n'est qu'une partie du programme.
  252. current = format_duration((self.step_end - now).total_seconds() * speed)
  253. if self.status.kind is Kind.ANIME:
  254. description += (f" (fin de l'épisode dans environ {current}) ; tu comptes regarder "
  255. f"jusqu'à l'épisode {self.program[-1][0].episode}, encore environ {total} en tout")
  256. else:
  257. description += (f" (fin du morceau dans environ {current}) ; encore {len(self.program)} "
  258. f"morceau(x) après celui-ci, environ {total} de musique en tout")
  259. return f"[ton statut Discord : {description}]"
  260. @property
  261. def available(self) -> bool:
  262. return self.status.available
  263. async def wait_turn(self) -> None:
  264. """Attend que le bot puisse répondre : dès qu'il redevient disponible, ou, en « Ne pas
  265. déranger », au bout d'un délai tiré au sort (DND_REPLY_MINUTES)."""
  266. loop = asyncio.get_running_loop()
  267. deadline: float | None = None
  268. while not self.available:
  269. changed = self._changed
  270. timeout = None
  271. if self.status.kind is Kind.DND:
  272. if deadline is None:
  273. minutes = random.uniform(*DND_REPLY_MINUTES)
  274. deadline = loop.time() + minutes * 60 / self.settings.presence_speed
  275. timeout = deadline - loop.time()
  276. if timeout <= 0:
  277. return
  278. try:
  279. await asyncio.wait_for(changed.wait(), timeout)
  280. except asyncio.TimeoutError:
  281. return
  282. def late_note(self, status: Status, several: bool) -> str:
  283. """Indique à Claude qu'il répond en retard, et pourquoi."""
  284. now = datetime.now(self.settings.timezone).strftime("%H:%M")
  285. subject = "Ces messages sont arrivés" if several else "Ce message est arrivé"
  286. return f"[{subject} pendant que tu étais {status.unavailability()} ; tu y réponds seulement maintenant, à {now}.]"
  287. def departure_note(self) -> str:
  288. """Demande à Claude de prévenir le salon qu'il s'absente (statut qui vient de changer)."""
  289. now = datetime.now(self.settings.timezone).strftime("%H:%M")
  290. return (f"[Il est {now} et tu passes à l'instant à ce statut, alors qu'une conversation est en cours "
  291. "dans le salon. Préviens en un court message, dans ton style et dans le fil de la discussion, "
  292. "que tu t'absentes, en accord avec ton statut. Ne pose pas de question : tu ne verras "
  293. "pas la réponse tout de suite.]")
  294. async def apply(self) -> None:
  295. """Envoie le statut à Discord (aussi rappelé après une reconnexion, qui l'efface)."""
  296. try:
  297. await self.bot.change_presence(activity=self.status.activity(), status=self.status.discord_status())
  298. except Exception as exc: # déconnecté : on_ready le renverra
  299. logger.warning("Statut Discord non appliqué : %s", exc)
  300. # ------------------------------------------------------------ boucles
  301. async def _run(self) -> None:
  302. self.sources.load_cache()
  303. await self.sources.refresh()
  304. self._tasks.append(asyncio.create_task(self._refresh_loop()))
  305. await self.bot.wait_until_ready()
  306. while True:
  307. try:
  308. async with self._step_lock:
  309. wake = await self._step()
  310. except Exception:
  311. logger.exception("Erreur inattendue dans la gestion du statut")
  312. wake = datetime.now(UTC) + timedelta(minutes=1)
  313. self._wake.clear()
  314. try:
  315. await asyncio.wait_for(self._wake.wait(), max(1.0, (wake - datetime.now(UTC)).total_seconds()))
  316. except asyncio.TimeoutError:
  317. pass
  318. async def skip(self) -> Status | None:
  319. """Abandonne le statut en cours (programme compris) et en tire un nouveau tout de suite.
  320. Renvoie le nouveau statut, ou None la nuit : le bot dort, il n'y a rien à passer."""
  321. async with self._step_lock:
  322. if self.status.kind is Kind.NIGHT or self.is_night(datetime.now(self.settings.timezone)):
  323. return None
  324. logger.info("Statut passé avant sa fin.")
  325. self.program = []
  326. self.step_end = datetime.min.replace(tzinfo=UTC)
  327. await self._step()
  328. self._wake.set() # la boucle recalcule son prochain réveil
  329. return self.status
  330. def remaining(self) -> str | None:
  331. """Durée restante du statut en cours, programme compris (None : inconnue)."""
  332. if self.status_end is None:
  333. return None
  334. seconds = (self.status_end - datetime.now(UTC)).total_seconds() * self.settings.presence_speed
  335. return format_duration(seconds)
  336. async def _refresh_loop(self) -> None:
  337. while True:
  338. await asyncio.sleep(SOURCES_REFRESH_INTERVAL)
  339. try:
  340. await self.sources.refresh()
  341. except Exception:
  342. logger.exception("Erreur inattendue pendant la mise à jour des listes de statuts")
  343. async def _step(self) -> datetime:
  344. """Passe à l'étape suivante quand la précédente est finie ; renvoie l'heure du prochain réveil."""
  345. night = self.settings.presence_night
  346. now = datetime.now(UTC)
  347. local = now.astimezone(self.settings.timezone)
  348. if self.is_night(local):
  349. if self.status.kind is not Kind.NIGHT:
  350. # Coupure nette : le statut en cours s'arrête, un nouveau sera tiré au réveil.
  351. self.program = []
  352. self.status_end = None
  353. await self.set_status(Status(Kind.NIGHT))
  354. self.step_end = self.next_hour(local, night[1])
  355. return self.step_end
  356. if now >= self.step_end:
  357. if not self.program:
  358. self.program = await self.draw()
  359. self.recent_kinds.append(self.program[0][0].kind) # celui affiché (en ligne si un jeu échoue…)
  360. total =sum(seconds for _, seconds in self.program)
  361. self.status_end = now + timedelta(seconds=total)
  362. logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
  363. status, seconds = self.program.pop(0)
  364. self.step_end = now + timedelta(seconds=seconds)
  365. await self.set_status(status)
  366. if night:
  367. return min(self.step_end, self.next_hour(local, night[0]))
  368. return self.step_end
  369. # ------------------------------------------------------------ tirage
  370. async def draw(self) -> list[Step]:
  371. """Tire un nouveau statut et son programme (durées divisées par PRESENCE_SPEED)."""
  372. available = {kind: weight for kind, weight in self.weights.items() if weight > 0 and self.has_source(kind)}
  373. # Les derniers types tirés reviennent moins, ou pas (voir RECENT_KIND_FACTORS), tant qu'il reste un autre choix.
  374. for recent, factor in zip(reversed(self.recent_kinds), RECENT_KIND_FACTORS):
  375. if recent not in available:
  376. continue
  377. if factor == 0 and len(available) > 1:
  378. del available[recent]
  379. elif factor > 0:
  380. available[recent] *= factor
  381. kind =random.choices(list(available), weights=list(available.values()))[0] if available else Kind.ONLINE
  382. speed = self.settings.presence_speed
  383. if kind is Kind.ANIME:
  384. anime = self.pick(self.sources.anime, lambda a: a.title == self.status.title)
  385. if anime.episodes == 1: # film
  386. return [(Status(kind, title=anime.title), anime.duration * 60 / speed)]
  387. available_episodes = anime.episodes or DEFAULT_ANIME_EPISODES
  388. count = min(random.randint(*ANIME_EPISODES), available_episodes)
  389. first = random.randint(1, available_episodes - count + 1)
  390. return [(Status(kind, title=anime.title, episode=n), anime.duration * 60 / speed)
  391. for n in range(first, first + count)]
  392. if kind is Kind.MUSIC:
  393. count = min(random.randint(*MUSIC_TRACKS), len(self.sources.music))
  394. tracks = random.sample(self.sources.music, count)
  395. return [(Status(kind, track=t), t.duration / speed) for t in tracks]
  396. seconds = random.randint(*MINUTES[kind.value]) * 60 if kind.value in MINUTES else 0
  397. if kind is Kind.GAME:
  398. game = await self.pick_game()
  399. if game is None:
  400. return [(Status(Kind.ONLINE), seconds / speed)]
  401. return [(Status(kind, title=game.name, details=game.description), seconds / speed)]
  402. if kind in (Kind.THOUGHT, Kind.IDLE, Kind.DND):
  403. text = await self.write_custom_status(kind, seconds)
  404. if kind is Kind.THOUGHT and not text:
  405. kind = Kind.ONLINE # pensée impossible à écrire : en ligne, sans activité
  406. return [(Status(kind, title=text), seconds / speed)]
  407. return [(Status(kind), seconds / speed)]
  408. async def pick_game(self) -> Game | None:
  409. """Jeu au hasard dans toute la boutique Steam, en écartant les jeux pour adultes."""
  410. for appid in random.sample(self.sources.games, min(GAME_ATTEMPTS, len(self.sources.games))):
  411. try:
  412. game = await self.sources.game_details(appid)
  413. except SourceError as exc:
  414. logger.warning("Fiche du jeu Steam %s illisible : %s", appid, exc)
  415. return None
  416. if game:
  417. return game
  418. logger.info("Jeu Steam %s écarté (introuvable, pas un jeu ou pour adultes).", appid)
  419. return None
  420. async def write_custom_status(self, kind: Kind, seconds: int) -> str | None:
  421. local = datetime.now(self.settings.timezone)
  422. now = f"{WEEKDAYS[local.weekday()]} {local.strftime('%d/%m/%Y, %H:%M')}"
  423. text = await self.claude.write_custom_status(
  424. kind.value, now, format_duration(seconds), list(self.recent_custom), CUSTOM_STATUS_MAX_LENGTH,
  425. )
  426. if text:
  427. self.recent_custom.append(text)
  428. return text
  429. def has_source(self, kind: Kind) -> bool:
  430. if kind is Kind.ANIME:
  431. return bool(self.sources.anime)
  432. if kind is Kind.MUSIC:
  433. return bool(self.sources.music)
  434. if kind is Kind.GAME:
  435. return bool(self.sources.games)
  436. return True
  437. @staticmethod
  438. def pick(items: list, is_current):
  439. """Élément au hasard, différent de l'actuel quand c'est possible."""
  440. return random.choice([item for item in items if not is_current(item)] or items)
  441. async def set_status(self, status: Status) -> None:
  442. before, self.status = self.status, status
  443. self._changed.set() # réveille les réponses en attente
  444. self._changed = asyncio.Event()
  445. logger.info("Statut Discord : %s", status.describe() or "en ligne, sans activité")
  446. self.save()
  447. await self.apply()
  448. self.bot.dispatch("status_change", before, status) # appelle on_status_change du bot
  449. # ------------------------------------------------------------ nuit
  450. def is_night(self, local: datetime) -> bool:
  451. if not self.settings.presence_night:
  452. return False
  453. start, end = self.settings.presence_night
  454. if start < end:
  455. return start <= local.hour < end
  456. return local.hour >= start or local.hour < end # nuit à cheval sur minuit (ex. 23-6)
  457. @staticmethod
  458. def next_hour(local: datetime, hour: int) -> datetime:
  459. """Prochain passage à hour:00, en UTC."""
  460. candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0)
  461. if candidate <= local:
  462. candidate += timedelta(days=1)
  463. return candidate.astimezone(UTC)