Procházet zdrojové kódy

Statut Discord : repris après un redémarrage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet před 3 dny
rodič
revize
2d2cc7061a
5 změnil soubory, kde provedl 88 přidání a 2 odebrání
  1. 1 0
      .dockerignore
  2. 1 0
      .gitignore
  3. 2 1
      README.md
  4. 73 1
      bot/presence.py
  5. 11 0
      changelog.txt

+ 1 - 0
.dockerignore

@@ -11,6 +11,7 @@
 
 **/conversation_history.json*
 **/presence_sources.json
+**/presence_state.json
 **/waiting_messages.json
 **/slash_commands.sha256
 **/bot.log*

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ venv/
 bot.log*
 conversation_history.json*
 presence_sources.json
+presence_state.json
 waiting_messages.json
 slash_commands.sha256
 .claude/

+ 2 - 1
README.md

@@ -27,7 +27,7 @@ Seul le nom du fichier reste ensuite en mémoire : le bot se souvient qu'il l'a
 
 ### Vie sur le serveur
 
-- **Statut Discord** (`PRESENCE=on`) : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu de la boutique Steam, affiche une pensée du moment, ou passe absent ou en « Ne pas déranger » avec une raison qu'il écrit lui-même. Il dort la nuit (`PRESENCE_NIGHT`) et sait ce qu'il est en train de faire quand on lui en parle.
+- **Statut Discord** (`PRESENCE=on`) : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu de la boutique Steam, affiche une pensée du moment, ou passe absent ou en « Ne pas déranger » avec une raison qu'il écrit lui-même. Il dort la nuit (`PRESENCE_NIGHT`) et sait ce qu'il est en train de faire quand on lui en parle. Après un redémarrage, il reprend son statut là où il en serait s'il ne s'était pas arrêté.
 - **Absences** : absent, il ne répond qu'à son retour ; en « Ne pas déranger », au bout de 5 à 15 minutes. S'il s'en va en pleine conversation, il prévient d'abord son salon. Les messages en attente sont gardés même après un redémarrage.
 - **Emojis du serveur** : le bot connaît automatiquement tous les emojis du serveur, animés compris, et peut les utiliser dans ses réponses. Un emoji ajouté ou retiré est pris en compte tout de suite.
 - **Stickers et emojis seuls** : à un sticker seul, il répond par un sticker du serveur ; à un emoji seul, par un emoji du serveur.
@@ -146,6 +146,7 @@ Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque app
 | `conversation_history.json` | Mémoire de chaque salon. Si le fichier est illisible, il est mis de côté sous un nom daté, jamais écrasé |
 | `waiting_messages.json` | Messages reçus pendant une absence, qui attendent leur réponse |
 | `presence_sources.json` | Listes d'animes, de musiques et de jeux, mises à jour une fois par jour |
+| `presence_state.json` | Statut Discord en cours, repris après un redémarrage |
 | `slash_commands.sha256` | Empreinte des commandes slash, pour ne les envoyer à Discord que quand elles changent |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 

+ 73 - 1
bot/presence.py

@@ -7,15 +7,20 @@ quelques morceaux, une session de jeu…) ; à la fin, un nouveau statut est tir
 Absent, le bot ne répond pas avant son retour ; en « Ne pas déranger », il répond au bout de
 quelques minutes (voir wait_turn). S'il était en pleine conversation dans son salon, il prévient
 d'abord qu'il s'absente (voir departure_note et ChatBot.on_status_change).
+
+Le statut en cours est gardé dans presence_state.json : après un redémarrage, le bot le reprend
+là où il en serait s'il ne s'était pas arrêté (voir restore).
 """
 
 from __future__ import annotations
 
 import asyncio
+import json
 import logging
+import os
 import random
 from collections import deque
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
 from datetime import datetime, timedelta, timezone
 from enum import Enum
 from typing import TYPE_CHECKING
@@ -128,6 +133,17 @@ class Status:
             return "tu es absente pour la nuit"
         return None
 
+    def to_dict(self) -> dict:
+        data = {"kind": self.kind.value, "title": self.title, "episode": self.episode, "details": self.details,
+                "track": asdict(self.track) if self.track else None}
+        return {key: value for key, value in data.items() if value is not None}
+
+    @classmethod
+    def from_dict(cls, data: dict) -> Status:
+        track = Track(**data["track"]) if data.get("track") else None
+        return cls(Kind(data["kind"]), title=data.get("title"), track=track,
+                   episode=data.get("episode"), details=data.get("details"))
+
 
 # Un statut et sa durée en secondes. Un anime ou une musique en enchaîne plusieurs (un par épisode
 # ou par morceau) : c'est le « programme » du statut.
@@ -156,6 +172,7 @@ class PresenceManager:
         self.step_end = datetime.min.replace(tzinfo=UTC)  # fin de l'étape en cours (UTC)
         self.status_end: datetime | None = None  # fin du statut en cours, toutes étapes comprises
         self.recent_custom: deque[str] = deque(maxlen=RECENT_CUSTOM_STATUSES)
+        self.state_file = settings.history_file.with_name("presence_state.json")
         self._tasks: list[asyncio.Task] = []
         self._changed = asyncio.Event()  # déclenché à chaque changement de statut (voir wait_turn)
 
@@ -164,8 +181,62 @@ class PresenceManager:
             logger.info("STEAM_API_KEY absent : pas de statut « joue à ».")
         if self.is_night(datetime.now(self.settings.timezone)):
             self.status = Status(Kind.NIGHT)  # démarrage la nuit : absent dès le premier message
+        else:
+            self.restore()
         self._tasks.append(asyncio.create_task(self._run()))
 
+    # ------------------------------------------------------------ sauvegarde
+
+    def save(self) -> None:
+        """Garde le statut en cours et la suite de son programme, pour les reprendre au redémarrage."""
+        data = {
+            "saved_at": datetime.now(UTC).isoformat(),
+            "status": self.status.to_dict(),
+            "step_end": self.step_end.isoformat(),
+            "status_end": self.status_end.isoformat() if self.status_end else None,
+            "program": [[status.to_dict(), seconds] for status, seconds in self.program],
+            "recent_custom": list(self.recent_custom),
+        }
+        tmp = self.state_file.with_suffix(".json.tmp")
+        try:
+            tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+            os.replace(tmp, self.state_file)
+        except OSError as exc:
+            logger.warning("Impossible d'écrire %s : %s", self.state_file, exc)
+
+    def restore(self) -> None:
+        """Reprend le statut d'avant le redémarrage, là où il en serait s'il avait continué
+        pendant l'arrêt (un anime avance d'autant d'épisodes). Un statut terminé entre-temps,
+        coupé par la nuit, ou désactivé depuis dans PRESENCE_WEIGHTS laisse place à un nouveau."""
+        try:
+            data = json.loads(self.state_file.read_text(encoding="utf-8"))
+            self.recent_custom.extend(data.get("recent_custom", []))
+            status = Status.from_dict(data["status"])
+            program = [(Status.from_dict(step), float(seconds)) for step, seconds in data.get("program", [])]
+            saved_at = datetime.fromisoformat(data["saved_at"])
+            step_end = datetime.fromisoformat(data["step_end"])
+            status_end = datetime.fromisoformat(data["status_end"]) if data.get("status_end") else None
+        except FileNotFoundError:
+            return
+        except (OSError, ValueError, TypeError, KeyError, AttributeError) as exc:
+            logger.warning("%s illisible, ignoré : %s", self.state_file, exc)
+            return
+
+        now = datetime.now(UTC)
+        if self.weights.get(status.kind, 0) <= 0:
+            return  # nuit, ou statut désactivé depuis dans PRESENCE_WEIGHTS
+        if night := self.settings.presence_night:
+            if self.next_hour(saved_at.astimezone(self.settings.timezone), night[0]) <= now:
+                return  # une nuit est passée depuis : le statut s'est arrêté avec elle
+        while step_end <= now and program:
+            status, seconds = program.pop(0)
+            step_end += timedelta(seconds=seconds)
+        if step_end <= now:
+            return
+        self.status, self.program, self.step_end, self.status_end = status, program, step_end, status_end
+        logger.info("Statut d'avant le redémarrage repris : %s, encore %s.", status.describe() or "en ligne",
+                    format_duration((step_end - now).total_seconds() * self.settings.presence_speed))
+
     def stop(self) -> None:
         for task in self._tasks:
             task.cancel()
@@ -374,6 +445,7 @@ class PresenceManager:
         self._changed.set()  # réveille les réponses en attente
         self._changed = asyncio.Event()
         logger.info("Statut Discord : %s", status.describe() or "en ligne, sans activité")
+        self.save()
         await self.apply()
         self.bot.dispatch("status_change", before, status)  # appelle on_status_change du bot
 

+ 11 - 0
changelog.txt

@@ -20,6 +20,17 @@ Nouveautés
   termines chaque message par un seul emoji du serveur, directement
   après le dernier mot. » Une ancienne liste de codes (<:hap:123…>)
   fonctionne encore, mais n'est plus utile.
+- Statut Discord gardé entre deux redémarrages : Rika reprend son statut
+  là où elle en serait si elle ne s'était pas arrêtée, comme s'il avait
+  continué pendant l'arrêt. Après un redémarrage rapide, elle regarde
+  toujours le même épisode ou joue au même jeu ; après un arrêt plus
+  long, l'anime a avancé d'autant d'épisodes et la playlist d'autant de
+  morceaux. Absente ou en « Ne pas déranger », elle le reste : les
+  messages en attente gardent leur réponse différée. Un statut terminé
+  pendant l'arrêt, coupé par la nuit ou désactivé depuis dans
+  PRESENCE_WEIGHTS laisse place à un nouveau, et ses derniers statuts
+  personnalisés ne sont pas répétés. Le statut est gardé dans
+  presence_state.json, à côté de l'historique.
 
 
 Version 5.8