Sfoglia il codice sorgente

/skip : passer au statut Discord suivant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 giorni fa
parent
commit
ac6f684bd3
4 ha cambiato i file con 101 aggiunte e 12 eliminazioni
  1. 3 2
      README.md
  2. 42 8
      bot/discord_bot.py
  3. 48 2
      bot/presence.py
  4. 8 0
      changelog.txt

+ 3 - 2
README.md

@@ -35,6 +35,7 @@ Seul le nom du fichier reste ensuite en mémoire : le bot se souvient qu'il l'a
 ### Modération
 
 - **`/reset`** : efface la mémoire du salon. Réservé aux modérateurs (« Gérer les messages ») et aux administrateurs.
+- **`/skip`** (avec `PRESENCE=on`) : abandonne le statut Discord en cours et en tire un nouveau tout de suite ; seul celui qui lance la commande voit le nouveau statut. Absent, le bot répond alors aux messages en attente s'il passe en ligne. Sans effet la nuit. Réservé aux mêmes personnes que `/reset`.
 - Le bot ne peut jamais pinger `@everyone`, `@here`, un rôle ou un membre.
 
 ## Avant de commencer
@@ -53,7 +54,7 @@ Il te faut :
    - clique sur **Reset Token** et copie le token : c'est le `DISCORD_TOKEN` (à garder secret) ;
    - dans **Privileged Gateway Intents**, active **Message Content Intent**. Les intents « Presence » et « Server Members » ne servent pas.
 3. Onglet **OAuth2 > URL Generator** :
-   - coche les scopes **bot** et **applications.commands** (ce dernier pour `/reset`) ;
+   - coche les scopes **bot** et **applications.commands** (ce dernier pour `/reset` et `/skip`) ;
    - dans les permissions, coche **View Channels**, **Send Messages** et **Read Message History** ;
    - ouvre l'adresse générée et invite le bot sur ton serveur.
 4. Dans Discord, active le mode développeur (**Paramètres utilisateur > Avancés > Mode développeur**), puis fais un clic droit sur le salon du bot > **Copier l'identifiant du salon** : c'est le `CHANNEL_ID`.
@@ -160,7 +161,7 @@ bot/config.py            lecture et validation du .env
 bot/models.py            particularités de chaque modèle Claude (réflexion, tarifs)
 bot/claude_client.py     appels à l'API de Claude, personnalité
 bot/history.py           mémoire des conversations
-bot/discord_bot.py       événements Discord, réponses, /reset
+bot/discord_bot.py       événements Discord, réponses, /reset et /skip
 bot/emojis.py            emojis du serveur : liste pour Claude, noms remplacés à l'envoi
 bot/presence.py          statut Discord : tirage au sort et changements
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)

+ 42 - 8
bot/discord_bot.py

@@ -133,6 +133,8 @@ class ChatBot(commands.Bot):
     async def setup_hook(self) -> None:
         # Exécuté une seule fois au démarrage (contrairement à on_ready).
         self.tree.add_command(reset_command)
+        if self.presence:
+            self.tree.add_command(skip_command)
         await self.sync_commands()
         s = self.settings
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s",
@@ -772,20 +774,28 @@ class ChatBot(commands.Bot):
 
 # ------------------------------------------------------------- commandes slash
 
+async def allowed(interaction: discord.Interaction) -> bool:
+    """Commande réservée aux modérateurs et administrateurs : prévient les autres et renvoie False."""
+    # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
+    perms = interaction.permissions
+    if perms.manage_messages or perms.administrator:
+        return True
+    logger.info("Tentative de /%s refusée pour %s dans le salon %s",
+                interaction.command.name if interaction.command else "?", interaction.user, interaction.channel_id)
+    await interaction.response.send_message(
+        "Seuls les modérateurs et administrateurs du serveur peuvent utiliser cette commande.",
+        ephemeral=True,
+    )
+    return False
+
+
 @app_commands.command(name="reset", description="Réinitialise la mémoire du bot dans ce salon")
 @app_commands.guild_only()
 # Masque la commande aux membres sans « Gérer les messages » (modérateurs) ;
 # les administrateurs ont implicitement toutes les permissions.
 @app_commands.default_permissions(manage_messages=True)
 async def reset_command(interaction: discord.Interaction) -> None:
-    # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
-    perms = interaction.permissions
-    if not (perms.manage_messages or perms.administrator):
-        logger.info("Tentative de /reset refusée pour %s dans le salon %s", interaction.user, interaction.channel_id)
-        await interaction.response.send_message(
-            "Seuls les modérateurs et administrateurs du serveur peuvent utiliser cette commande.",
-            ephemeral=True,
-        )
+    if not await allowed(interaction):
         return
     bot: ChatBot = interaction.client  # type: ignore[assignment]
     # Le reset attend la fin d'une éventuelle réponse en cours, ce qui peut dépasser
@@ -794,3 +804,27 @@ async def reset_command(interaction: discord.Interaction) -> None:
     await bot.history.reset(interaction.channel_id)
     logger.info("Historique réinitialisé dans le salon %s par %s", interaction.channel_id, interaction.user)
     await interaction.followup.send("L'historique de conversation a été réinitialisé.")
+
+
+@app_commands.command(name="skip", description="Passe tout de suite au statut Discord suivant du bot")
+@app_commands.guild_only()
+@app_commands.default_permissions(manage_messages=True)  # comme /reset
+async def skip_command(interaction: discord.Interaction) -> None:
+    if not await allowed(interaction):
+        return
+    bot: ChatBot = interaction.client  # type: ignore[assignment]
+    # Une pensée ou une raison d'absence est écrite par Claude : plus de 3 secondes possibles.
+    await interaction.response.defer(ephemeral=True)
+    try:
+        status = await bot.presence.skip()
+    except Exception:
+        logger.exception("Erreur inattendue pendant /skip")
+        await interaction.followup.send("Le statut n'a pas pu être changé, regarde les logs.", ephemeral=True)
+        return
+    if status is None:
+        await interaction.followup.send("C'est la nuit : le bot dort, il n'y a pas de statut à passer.", ephemeral=True)
+        return
+    logger.info("Statut passé par %s", interaction.user)
+    remaining = bot.presence.remaining()
+    duration = f", pendant environ {remaining}" if remaining else ""
+    await interaction.followup.send(f"Nouveau statut : {status.label()}{duration}.", ephemeral=True)

+ 48 - 2
bot/presence.py

@@ -133,6 +133,24 @@ class Status:
             return "tu es absente pour la nuit"
         return None
 
+    def label(self) -> str:
+        """Le statut tel qu'il s'affiche sur Discord, pour les modérateurs (/skip)."""
+        reason = f" (« {self.title} »)" if self.title else ""
+        if self.kind is Kind.ANIME:
+            episode = f"l'épisode {self.episode} de " if self.episode else ""
+            return f"regarde {episode}« {self.title} »"
+        if self.kind is Kind.MUSIC:
+            return f"écoute « {self.track.title} » de {self.track.artist}"
+        if self.kind is Kind.GAME:
+            return f"joue à « {self.title} »"
+        if self.kind is Kind.THOUGHT:
+            return f"pensée du moment « {self.title} »"
+        if self.kind is Kind.DND:
+            return f"Ne pas déranger{reason}"
+        if self.kind in (Kind.IDLE, Kind.NIGHT):
+            return f"absent{reason}"
+        return "en ligne, sans activité"
+
     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}
@@ -175,6 +193,8 @@ class PresenceManager:
         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)
+        self._step_lock = asyncio.Lock()  # /skip et la boucle ne changent pas le statut en même temps
+        self._wake = asyncio.Event()  # réveille la boucle après un /skip
 
     def start(self) -> None:
         if not self.sources.steam_enabled:
@@ -321,11 +341,37 @@ class PresenceManager:
         await self.bot.wait_until_ready()
         while True:
             try:
-                wake = await self._step()
+                async with self._step_lock:
+                    wake = await self._step()
             except Exception:
                 logger.exception("Erreur inattendue dans la gestion du statut")
                 wake = datetime.now(UTC) + timedelta(minutes=1)
-            await asyncio.sleep(max(1.0, (wake - datetime.now(UTC)).total_seconds()))
+            self._wake.clear()
+            try:
+                await asyncio.wait_for(self._wake.wait(), max(1.0, (wake - datetime.now(UTC)).total_seconds()))
+            except asyncio.TimeoutError:
+                pass
+
+    async def skip(self) -> Status | None:
+        """Abandonne le statut en cours (programme compris) et en tire un nouveau tout de suite.
+
+        Renvoie le nouveau statut, ou None la nuit : le bot dort, il n'y a rien à passer."""
+        async with self._step_lock:
+            if self.status.kind is Kind.NIGHT or self.is_night(datetime.now(self.settings.timezone)):
+                return None
+            logger.info("Statut passé avant sa fin.")
+            self.program = []
+            self.step_end = datetime.min.replace(tzinfo=UTC)
+            await self._step()
+        self._wake.set()  # la boucle recalcule son prochain réveil
+        return self.status
+
+    def remaining(self) -> str | None:
+        """Durée restante du statut en cours, programme compris (None : inconnue)."""
+        if self.status_end is None:
+            return None
+        seconds = (self.status_end - datetime.now(UTC)).total_seconds() * self.settings.presence_speed
+        return format_duration(seconds)
 
     async def _refresh_loop(self) -> None:
         while True:

+ 8 - 0
changelog.txt

@@ -31,6 +31,14 @@ Nouveautés
   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.
+- /skip : abandonne le statut Discord en cours (l'anime ou la playlist
+  entière) et en tire un nouveau tout de suite, jamais du même type.
+  Réservé aux modérateurs et administrateurs, comme /reset ; seul celui
+  qui lance la commande voit le nouveau statut et sa durée. Si Rika
+  était absente et passe en ligne, elle répond aussitôt aux messages en
+  attente ; si elle s'éloigne en pleine conversation, elle prévient son
+  salon. Sans effet la nuit, et absente avec PRESENCE=off. La commande
+  est envoyée à Discord au premier démarrage de la 5.9.
 
 
 Version 5.8