소스 검색

Absente et Ne pas déranger : réponses différées

Absente (et la nuit), Rika ne répond qu'à son retour ; en « Ne pas
déranger », au bout de 5 à 15 minutes ou dès qu'elle change de statut.
Les messages de son salon reçoivent une réponse groupée, les mentions
ailleurs une réponse avec « Répondre ». Elle sait qu'elle répond en
retard et pourquoi. Les messages en attente sont gardés dans
waiting_messages.json pour survivre à un redémarrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 일 전
부모
커밋
f729106561
7개의 변경된 파일과 204개의 추가작업 그리고 28개의 파일을 삭제
  1. 1 0
      .dockerignore
  2. 1 0
      .gitignore
  3. 1 1
      README.md
  4. 4 1
      bot/claude_client.py
  5. 134 24
      bot/discord_bot.py
  6. 52 0
      bot/presence.py
  7. 11 2
      changelog.txt

+ 1 - 0
.dockerignore

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

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ venv/
 bot.log*
 conversation_history.json*
 presence_sources.json
+waiting_messages.json
 .claude/
 .vscode/
 # Personnalité perso (peut contenir des infos privées)

+ 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. 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. 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.

+ 4 - 1
bot/claude_client.py

@@ -70,7 +70,10 @@ PRESENCE_NOTE = (
     "(anime que tu regardes, musique que tu écoutes, jeu auquel tu joues, statut personnalisé ou disponibilité) ; "
     "tout le serveur le voit sous ton pseudo. C'est vraiment ce que tu es en train de faire : tu peux en parler "
     "naturellement si c'est pertinent ou si on te le demande, mais pas à chaque message, et sans recopier cette mention. "
-    "Seul le statut du dernier message est d'actualité."
+    "Seul le statut du dernier message est d'actualité. "
+    "Quand tu es absente, tu ne réponds qu'à ton retour, et en « Ne pas déranger », seulement au bout de "
+    "quelques minutes : « [Ces messages sont arrivés pendant que tu étais …] » te l'indique. Réponds alors "
+    "comme quelqu'un qui revient, naturellement, sans forcément t'excuser."
 )
 
 # Demandes de statut personnalisé (pensée du moment, raison d'une absence), écrit avec la

+ 134 - 24
bot/discord_bot.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import asyncio
+import json
 import logging
 import random
 import re
@@ -17,7 +18,7 @@ from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
 from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
 from .images import ImageError, prepare_image
-from .presence import PresenceManager
+from .presence import PresenceManager, Status
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
 
@@ -77,6 +78,12 @@ class ChatBot(commands.Bot):
         self._pending: dict[int, list[PendingMessage]] = {}  # messages en attente de réponse, par salon
         self._announced = False
         self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
+        # Absent ou en « Ne pas déranger » : réponses différées, par salon.
+        self._waiters: dict[int, asyncio.Task] = {}  # attente en cours
+        self._mentions: dict[int, list[discord.Message]] = {}  # mentions en attente (hors salon dédié)
+        # 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)
 
     # ------------------------------------------------------------ cycle de vie
 
@@ -99,6 +106,7 @@ class ChatBot(commands.Bot):
         if self._announced:
             return  # simple reconnexion : on ne reposte pas le message de démarrage
         self._announced = True
+        self._replay_task = asyncio.create_task(self.replay_waiting())  # référence gardée : pas de ramasse-miettes
 
         channel = self.get_channel(self.settings.channel_id)
         if channel is None:
@@ -119,6 +127,8 @@ class ChatBot(commands.Bot):
     async def close(self) -> None:
         if self.presence:
             self.presence.stop()
+        for task in self._waiters.values():
+            task.cancel()  # les messages en attente restent dans waiting_messages.json
         await self.claude.close()
         await super().close()
 
@@ -145,9 +155,10 @@ class ChatBot(commands.Bot):
             await self.handle_mention(message)
 
     async def handle_main_channel(self, message: discord.Message) -> None:
-        if await self.reply_to_sticker(message):
-            return
-        if await self.reply_to_emoji(message):
+        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
+        elif await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
             return
 
         forward, forward_image = await self.read_forward(message)
@@ -176,39 +187,67 @@ class ChatBot(commands.Bot):
 
         # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
         # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
-        pending = self._pending.setdefault(message.channel.id, [])
-        pending.append(PendingMessage(message, user_text, stored_text, image))
-        async with self.history.lock(message.channel.id):
+        self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, user_text, stored_text, image))
+        if not self.available() or message.channel.id in self._waiters:
+            self.answer_later(message, self.answer_main_channel)
+            return
+        await self.answer_main_channel(message.channel)
+
+    async def answer_main_channel(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
+        """Répond en une fois aux messages en attente du salon dédié."""
+        pending = self._pending.setdefault(channel.id, [])
+        async with self.history.lock(channel.id):
             if not pending:
                 return  # déjà parti avec un message précédent
             batch = sorted(pending, key=lambda p: p.message.id)
             pending.clear()
             last = batch[-1].message
             if len(batch) > 1:
-                logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), message.channel)
+                logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
             # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
             parts = [(p.user_text, p.image) for p in batch]
             stored_lines = [p.stored_text for p in batch]
+            notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
             if note := self.presence_note():
+                notes.append(note)
+            for note in reversed(notes):
                 parts.insert(0, (note, None))
                 stored_lines.insert(0, note)
-            async with message.channel.typing():
-                try:
-                    answer = await self.claude.ask(self.history.get(message.channel.id), parts)
-                except ClaudeError as exc:
-                    await self.safe_reply(last, exc.user_message)
-                    return
-                except Exception:
-                    logger.exception("Erreur inattendue pendant le traitement d'un message")
-                    await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
-                    return
+            try:
+                async with channel.typing():
+                    try:
+                        answer = await self.claude.ask(self.history.get(channel.id), parts)
+                    except ClaudeError as exc:
+                        await self.safe_reply(last, exc.user_message)
+                        return
+                    except Exception:
+                        logger.exception("Erreur inattendue pendant le traitement d'un message")
+                        await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
+                        return
+            finally:
+                self.forget_waiting(*(p.message for p in batch))
             stored_text = "\n".join(stored_lines)
             if answer.web_used:
                 stored_text += f" {WEB_MARKER}"
-            await self.history.add_exchange(message.channel.id, stored_text, answer.text)
+            await self.history.add_exchange(channel.id, stored_text, answer.text)
         await self.send_answer(last, answer.text)
 
     async def handle_mention(self, message: discord.Message) -> None:
+        if not self.available() or message.channel.id in self._waiters:
+            self._mentions.setdefault(message.channel.id, []).append(message)
+            self.answer_later(message, self.answer_mentions)
+            return
+        await self.answer_mention(message)
+
+    async def answer_mentions(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
+        """Répond, une par une, aux mentions reçues pendant une absence (avec « Répondre »)."""
+        for message in sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id):
+            try:
+                await self.answer_mention(message, late)
+            finally:
+                self.forget_waiting(message)
+
+    async def answer_mention(self, message: discord.Message, late: Status | None = None) -> None:
         """Hors du salon dédié : répond à une mention en lisant les messages récents."""
         lines: list[str] = []
         if self.settings.context_message_limit > 0:
@@ -241,6 +280,8 @@ class ChatBot(commands.Bot):
         channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
 
         parts = []
+        if late:
+            parts.append(self.presence.late_note(late, several=False))
         if note := self.presence_note():
             parts.append(note)
         if lines:
@@ -267,7 +308,73 @@ class ChatBot(commands.Bot):
                 logger.exception("Erreur inattendue pendant le traitement d'une mention")
                 await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
                 return
-        await self.send_answer(message, answer.text)
+        # En retard, la réponse cite la mention : on sait à quoi elle répond.
+        await self.send_answer(message, answer.text, reply=late is not None)
+
+    # ------------------------------------------------------ réponses différées
+
+    def available(self) -> bool:
+        return self.presence is None or self.presence.available
+
+    def answer_later(self, message: discord.Message, answer) -> None:
+        """Met le message de côté : answer(salon, late=statut) y répondra quand le bot le pourra."""
+        self.remember_waiting(message)
+        channel = message.channel
+        if channel.id in self._waiters:
+            return  # une attente est déjà en cours dans ce salon : le message en profitera
+        status = self.presence.status
+        logger.info("Réponse différée dans #%s : %s.", channel, status.unavailability())
+        self._waiters[channel.id] = asyncio.create_task(self._answer_when_possible(channel, answer, status))
+
+    async def _answer_when_possible(self, channel: discord.abc.Messageable, answer, status: Status) -> None:
+        try:
+            await self.presence.wait_turn()
+        finally:
+            self._waiters.pop(channel.id, None)
+        try:
+            await answer(channel, late=status)
+        except Exception:
+            logger.exception("Erreur inattendue pendant une réponse différée")
+
+    def remember_waiting(self, message: discord.Message) -> None:
+        self._waiting.add((message.channel.id, message.id))
+        self._save_waiting()
+
+    def forget_waiting(self, *messages: discord.Message) -> None:
+        keys = {(m.channel.id, m.id) for m in messages}
+        if keys & self._waiting:
+            self._waiting -= keys
+            self._save_waiting()
+
+    def _save_waiting(self) -> None:
+        try:
+            self._waiting_file.write_text(json.dumps(sorted(self._waiting)), encoding="utf-8")
+        except OSError as exc:
+            logger.warning("Impossible d'écrire %s : %s", self._waiting_file, exc)
+
+    async def replay_waiting(self) -> None:
+        """Au démarrage : reprend les messages restés sans réponse avant le redémarrage."""
+        try:
+            saved = json.loads(self._waiting_file.read_text(encoding="utf-8"))
+        except FileNotFoundError:
+            return
+        except (OSError, ValueError) as exc:
+            logger.warning("%s illisible, ignoré : %s", self._waiting_file, exc)
+            return
+        messages = []
+        for channel_id, message_id in saved:
+            try:
+                channel = self.get_channel(channel_id) or await self.fetch_channel(channel_id)
+                messages.append(await channel.fetch_message(message_id))
+            except discord.HTTPException:
+                pass  # message supprimé, salon inaccessible…
+        self._waiting.clear()
+        self._save_waiting()
+        if messages:
+            logger.info("%d message(s) resté(s) sans réponse avant le redémarrage : reprise.", len(messages))
+            # En parallèle, comme à leur arrivée : ceux du salon dédié sont regroupés.
+            await asyncio.gather(*(self.on_message(m) for m in sorted(messages, key=lambda m: m.id)),
+                                 return_exceptions=True)
 
     # ------------------------------------------------------------- réactions
 
@@ -463,11 +570,14 @@ class ChatBot(commands.Bot):
     def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
         return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
 
-    async def send_answer(self, message: discord.Message, answer: str) -> None:
-        # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre ».
-        for chunk in split_message(answer):
+    async def send_answer(self, message: discord.Message, answer: str, reply: bool = False) -> None:
+        # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre »
+        # (sauf reply=True : le premier morceau répond alors au message).
+        for i, chunk in enumerate(split_message(answer)):
+            # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
+            reference = message.to_reference(fail_if_not_exists=False) if reply and i == 0 else None
             try:
-                await message.channel.send(chunk)
+                await message.channel.send(chunk, reference=reference, mention_author=False)
             except discord.HTTPException as exc:
                 logger.error("Envoi de la réponse impossible : %s", exc)
                 return

+ 52 - 0
bot/presence.py

@@ -3,6 +3,9 @@
 Chaque statut est tiré au sort selon PRESENCE_WEIGHTS, avec sa propre durée (quelques épisodes,
 quelques morceaux, une session de jeu…) ; à la fin, un nouveau statut est tiré. Pendant la nuit
 (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).
 """
 
 from __future__ import annotations
@@ -33,6 +36,7 @@ SOURCES_REFRESH_INTERVAL = 24 * 3600  # secondes
 CUSTOM_STATUS_MAX_LENGTH = 128  # limite de Discord
 RECENT_CUSTOM_STATUSES = 4  # statuts personnalisés récents, à ne pas répéter
 GAME_ATTEMPTS = 5  # jeux essayés avant d'abandonner (fiche introuvable, jeu pour adultes…)
+DND_REPLY_MINUTES = (5, 15)  # délai de réponse en « Ne pas déranger »
 
 # Durée de chaque statut, tirée au sort entre ces bornes.
 ANIME_EPISODES = (1, 5)
@@ -89,6 +93,19 @@ class Status:
             return discord.Status.idle
         return discord.Status.online
 
+    @property
+    def available(self) -> bool:
+        return self.kind not in (Kind.DND, Kind.IDLE, Kind.NIGHT)
+
+    def unavailability(self) -> str:
+        """Pourquoi le bot ne répondait pas, vu de lui-même (statut absent ou Ne pas déranger)."""
+        reason = f" (statut affiché : « {self.title} »)" if self.title else ""
+        if self.kind is Kind.NIGHT:
+            return "absente pour la nuit"
+        if self.kind is Kind.DND:
+            return f"en « Ne pas déranger »{reason}"
+        return f"absente{reason}"
+
     def describe(self) -> str | None:
         """Ce que fait le bot, vu de lui-même (None : en ligne, sans activité)."""
         reason = f" (statut affiché : « {self.title} »)" if self.title else ""
@@ -139,10 +156,13 @@ class PresenceManager:
         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._tasks: list[asyncio.Task] = []
+        self._changed = asyncio.Event()  # déclenché à chaque changement de statut (voir wait_turn)
 
     def start(self) -> None:
         if not self.sources.steam_enabled:
             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
         self._tasks.append(asyncio.create_task(self._run()))
 
     def stop(self) -> None:
@@ -175,6 +195,36 @@ class PresenceManager:
                                     f"morceau(x) après celui-ci, environ {total} de musique en tout")
         return f"[ton statut Discord : {description}]"
 
+    @property
+    def available(self) -> bool:
+        return self.status.available
+
+    async def wait_turn(self) -> None:
+        """Attend que le bot puisse répondre : dès qu'il redevient disponible, ou, en « Ne pas
+        déranger », au bout d'un délai tiré au sort (DND_REPLY_MINUTES)."""
+        loop = asyncio.get_running_loop()
+        deadline: float | None = None
+        while not self.available:
+            changed = self._changed
+            timeout = None
+            if self.status.kind is Kind.DND:
+                if deadline is None:
+                    minutes = random.uniform(*DND_REPLY_MINUTES)
+                    deadline = loop.time() + minutes * 60 / self.settings.presence_speed
+                timeout = deadline - loop.time()
+                if timeout <= 0:
+                    return
+            try:
+                await asyncio.wait_for(changed.wait(), timeout)
+            except asyncio.TimeoutError:
+                return
+
+    def late_note(self, status: Status, several: bool) -> str:
+        """Indique à Claude qu'il répond en retard, et pourquoi."""
+        now = datetime.now(self.settings.timezone).strftime("%H:%M")
+        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}.]"
+
     async def apply(self) -> None:
         """Envoie le statut à Discord (aussi rappelé après une reconnexion, qui l'efface)."""
         try:
@@ -312,6 +362,8 @@ class PresenceManager:
 
     async def set_status(self, status: Status) -> None:
         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()
 

+ 11 - 2
changelog.txt

@@ -36,8 +36,17 @@ Nouveautés
   - 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) au cas où
     un site ne répondrait plus.
-  - Pour l'instant, « Ne pas déranger » et absente ne changent que
-    l'affichage : Rika répond toujours tout de suite.
+- Absente (et la nuit), Rika ne répond plus avant son retour ; en « Ne pas
+  déranger », elle répond au bout de 5 à 15 minutes, ou dès qu'elle change
+  de statut.
+  - Dans son salon, tous les messages arrivés entre-temps reçoivent une
+    seule réponse groupée. Ailleurs, elle répond à chaque mention avec
+    « Répondre », pour qu'on sache à quel message elle répond.
+  - Elle sait qu'elle répond en retard, et pourquoi (absente, Ne pas
+    déranger, la nuit, avec la raison affichée).
+  - Elle ne réagit pas aux stickers ni aux emojis seuls pendant ce temps.
+  - Les messages en attente sont gardés dans waiting_messages.json (à côté
+    de l'historique) : après un redémarrage, Rika y répond quand même.
 
 
 Version 5.5 — 29/10/2026