Browse Source

Absente et Ne pas déranger : Rika prévient qu'elle s'en va

Quand Rika passe absente, en « Ne pas déranger » ou part dormir alors
que quelqu'un a écrit dans son salon il y a moins de 3 minutes, elle
prévient d'abord, en un court message écrit par Claude et en accord avec
son statut. Elle finit sa réponse en cours avant, et garde ce message en
mémoire. Ouverture de la version 5.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 days ago
parent
commit
e6fe5cf8d9
5 changed files with 63 additions and 4 deletions
  1. 1 1
      README.md
  2. 1 1
      bot/__init__.py
  3. 38 0
      bot/discord_bot.py
  4. 12 2
      bot/presence.py
  5. 11 0
      changelog.txt

+ 1 - 1
README.md

@@ -12,7 +12,7 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 - **Transferts** : dans le salon dédié, le bot lit un message transféré (son texte et son image).
 - **Autres bots** : le bot ignore les autres bots, sauf quand ils le mentionnent ou répondent à l'un de ses messages (bouton « Répondre ») : deux bots peuvent ainsi discuter ensemble. Les messages système (arrivées, épinglages, boosts…) sont ignorés.
 - **Stickers et emojis** : répond à un sticker par un sticker du serveur, et à un emoji personnalisé seul par un emoji du serveur.
-- **Statut Discord** : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu (toute la boutique Steam, sans les jeux pour adultes), affiche une pensée du moment, ou passe en « Ne pas déranger » ou absent avec une raison écrite par Claude. Chaque statut a sa propre durée (réglages `PRESENCE_*` du `.env`), le bot est absent la nuit, et il sait ce qu'il est en train de faire quand on lui parle. Absent, il ne répond qu'à son retour ; en « Ne pas déranger », au bout de 5 à 15 minutes. Les messages reçus entre-temps reçoivent une réponse groupée dans le salon dédié, et une réponse à chaque mention ailleurs (gardés dans `waiting_messages.json`, même après un redémarrage). Les listes sont téléchargées au démarrage puis une fois par jour, et gardées dans `presence_sources.json` à côté de l'historique.
+- **Statut Discord** : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu (toute la boutique Steam, sans les jeux pour adultes), affiche une pensée du moment, ou passe en « Ne pas déranger » ou absent avec une raison écrite par Claude. Chaque statut a sa propre durée (réglages `PRESENCE_*` du `.env`), le bot est absent la nuit, et il sait ce qu'il est en train de faire quand on lui parle. Absent, il ne répond qu'à son retour ; en « Ne pas déranger », au bout de 5 à 15 minutes. S'il change de statut en pleine conversation (message dans son salon depuis moins de 3 minutes), il prévient d'abord qu'il s'absente. Les messages reçus entre-temps reçoivent une réponse groupée dans le salon dédié, et une réponse à chaque mention ailleurs (gardés dans `waiting_messages.json`, même après un redémarrage). Les listes sont téléchargées au démarrage puis une fois par jour, et gardées dans `presence_sources.json` à côté de l'historique.
 - **`/reset`** : efface la mémoire du salon (réservé aux modérateurs et administrateurs).
 - **Personnalité** modifiable dans `personality_prompt.txt`, prise en compte sans redémarrer. Pour une personnalité perso, crée `personality_prompt.local.txt` et indique-le dans `PERSONALITY_FILE` du `.env` : les fichiers `*.local.txt` ne sont jamais envoyés sur GitHub.
 - **Logs** dans `bot.log`, avec le coût estimé de chaque appel.

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 
-VERSION = "5.6"
+VERSION = "5.7"

+ 38 - 0
bot/discord_bot.py

@@ -8,6 +8,7 @@ import logging
 import random
 import re
 from dataclasses import dataclass
+from datetime import datetime, timedelta
 
 import discord
 from discord import app_commands
@@ -32,6 +33,8 @@ QUOTE_MAX_LENGTH = 500
 # Messages écrits par quelqu'un ; les autres types sont des messages système
 # (arrivée d'un membre, message épinglé, boost, fil créé…).
 USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
+# En passant absent ou en « Ne pas déranger », le bot prévient son salon si on y a parlé depuis moins de :
+DEPARTURE_WINDOW = timedelta(minutes=3)
 
 
 def looks_like_image(attachment: discord.Attachment) -> bool:
@@ -84,6 +87,7 @@ class ChatBot(commands.Bot):
         # Messages en attente, gardés sur le disque pour y répondre même après un redémarrage.
         self._waiting_file = settings.history_file.with_name("waiting_messages.json")
         self._waiting: set[tuple[int, int]] = set()  # (salon, message)
+        self._last_main_message: datetime | None = None  # date du dernier message reçu dans le salon dédié
 
     # ------------------------------------------------------------ cycle de vie
 
@@ -155,6 +159,8 @@ class ChatBot(commands.Bot):
             await self.handle_mention(message)
 
     async def handle_main_channel(self, message: discord.Message) -> None:
+        if self._last_main_message is None or message.created_at > self._last_main_message:
+            self._last_main_message = message.created_at
         if not self.available():
             if message.stickers or CUSTOM_EMOJI_ONLY.match(message.content.strip()):
                 return  # absent : pas de réaction aux stickers ni aux emojis
@@ -336,6 +342,38 @@ class ChatBot(commands.Bot):
         except Exception:
             logger.exception("Erreur inattendue pendant une réponse différée")
 
+    async def on_status_change(self, before: Status, after: Status) -> None:
+        """Passe absent ou en « Ne pas déranger » : prévient le salon dédié si une conversation y est en cours."""
+        if not before.available or after.available or self._last_main_message is None:
+            return
+        if discord.utils.utcnow() - self._last_main_message > DEPARTURE_WINDOW:
+            return
+        channel = self.get_channel(self.settings.channel_id)
+        if channel is None:
+            return
+        async with self.history.lock(channel.id):
+            # Attend la fin d'une réponse en cours ; le statut a pu changer encore entre-temps.
+            if self.presence.status is not after:
+                return
+            parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
+            try:
+                async with channel.typing():
+                    answer = await self.claude.ask(self.history.get(channel.id), parts)
+            except ClaudeError as exc:
+                logger.warning("Message d'absence non envoyé : %s", exc.user_message)
+                return
+            stored_text = "\n".join(text for text, _ in parts)
+            if answer.web_used:
+                stored_text += f" {WEB_MARKER}"
+            await self.history.add_exchange(channel.id, stored_text, answer.text)
+        logger.info("Conversation en cours dans #%s : le salon est prévenu de l'absence.", channel)
+        for chunk in split_message(answer.text):
+            try:
+                await channel.send(chunk)
+            except discord.HTTPException as exc:
+                logger.error("Envoi du message d'absence impossible : %s", exc)
+                return
+
     def remember_waiting(self, message: discord.Message) -> None:
         self._waiting.add((message.channel.id, message.id))
         self._save_waiting()

+ 12 - 2
bot/presence.py

@@ -5,7 +5,8 @@ quelques morceaux, une session de jeu…) ; à la fin, un nouveau statut est tir
 (PRESENCE_NIGHT), le bot est absent.
 
 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).
+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).
 """
 
 from __future__ import annotations
@@ -225,6 +226,14 @@ class PresenceManager:
         subject = "Ces messages sont arrivés" if several else "Ce message est arrivé"
         return f"[{subject} pendant que tu étais {status.unavailability()} ; tu y réponds seulement maintenant, à {now}.]"
 
+    def departure_note(self) -> str:
+        """Demande à Claude de prévenir le salon qu'il s'absente (statut qui vient de changer)."""
+        now = datetime.now(self.settings.timezone).strftime("%H:%M")
+        return (f"[Il est {now} et tu passes à l'instant à ce statut, alors qu'une conversation est en cours "
+                "dans le salon. Préviens en un court message, dans ton style et dans le fil de la discussion, "
+                "que tu t'absentes, en accord avec ton statut. Ne pose pas de question : tu ne verras "
+                "pas la réponse tout de suite.]")
+
     async def apply(self) -> None:
         """Envoie le statut à Discord (aussi rappelé après une reconnexion, qui l'efface)."""
         try:
@@ -361,11 +370,12 @@ class PresenceManager:
         return random.choice([item for item in items if not is_current(item)] or items)
 
     async def set_status(self, status: Status) -> None:
-        self.status = status
+        before, self.status = self.status, status
         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é")
         await self.apply()
+        self.bot.dispatch("status_change", before, status)  # appelle on_status_change du bot
 
     # ------------------------------------------------------------ nuit
 

+ 11 - 0
changelog.txt

@@ -2,6 +2,17 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.7 — 30/10/2026
+------------------------
+
+Nouveautés
+- Quand Rika passe absente ou en « Ne pas déranger » (ou part dormir) en
+  pleine conversation, elle prévient son salon dans un court message, en
+  accord avec son statut. Elle ne le fait que si quelqu'un y a écrit dans
+  les 3 dernières minutes, et après avoir fini sa réponse en cours ; elle
+  s'en souvient ensuite.
+
+
 Version 5.6 — 30/10/2026
 ------------------------