Pārlūkot izejas kodu

Statut Discord : anime, musique, jeu, pensée et absences

Rika affiche sous son pseudo ce qu'elle fait : quelques épisodes d'un
anime du top 500 d'AniList, quelques morceaux des tops Deezer, une
session de jeu de la bibliothèque Steam, une pensée du moment, ou une
absence dont Claude écrit la raison. Chaque statut a sa propre durée,
deux statuts identiques ne se suivent jamais, et elle est absente de
2 h à 6 h. Quand on lui parle, elle sait ce qu'elle fait et pour
combien de temps encore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 dienas atpakaļ
vecāks
revīzija
ee26041e4c
11 mainītis faili ar 699 papildinājumiem un 5 dzēšanām
  1. 1 0
      .dockerignore
  2. 20 0
      .env.exemple
  3. 1 0
      .gitignore
  4. 3 0
      README.md
  5. 1 1
      bot/__init__.py
  6. 64 0
      bot/claude_client.py
  7. 75 0
      bot/config.py
  8. 21 4
      bot/discord_bot.py
  9. 316 0
      bot/presence.py
  10. 160 0
      bot/presence_sources.py
  11. 37 0
      changelog.txt

+ 1 - 0
.dockerignore

@@ -10,6 +10,7 @@
 **/*.key
 
 **/conversation_history.json*
+**/presence_sources.json
 **/bot.log*
 
 .venv/

+ 20 - 0
.env.exemple

@@ -42,6 +42,26 @@ PERSONALITY_FILE=personality_prompt.txt
 # Taille max en octets
 MAX_IMAGE_SIZE=25000000
 
+# --- Statut Discord ------------------------------------------------------
+# Statut affiché sous le pseudo du bot (anime regardé, musique écoutée, jeu…) : on ou off
+PRESENCE=on
+# Chaque statut a sa durée : 1 à 5 épisodes d'anime, 6 à 20 morceaux, 45 min à 2 h de jeu,
+# 15 à 45 min en ligne, 30 à 90 min de pensée, 10 à 60 min d'absence, 30 à 90 min en Ne pas déranger.
+# Chances de chaque statut à la fin du précédent. Vide = valeurs ci-dessous.
+# Un statut absent de la liste garde sa valeur par défaut ; 0 le désactive.
+PRESENCE_WEIGHTS=anime=20,musique=20,pensee=10,jeu=20,en_ligne=20,ne_pas_deranger=5,absent=5
+# Heures pendant lesquelles le bot est absent (de 2 h à 6 h), ou off
+PRESENCE_NIGHT=2-6
+# Pour tester : 10 = statuts 10 fois plus courts. Laisse 1 en temps normal.
+PRESENCE_SPEED=1
+# Playlists Deezer où piocher les musiques. Vide = Top Worldwide, Top France et Top Japan.
+DEEZER_PLAYLISTS=
+# Bibliothèque Steam où piocher les jeux. Sans ces deux valeurs, pas de statut « joue à ».
+# Clé gratuite : https://steamcommunity.com/dev/apikey (à garder secrète, comme les autres clés).
+# STEAM_ID : identifiant à 17 chiffres du profil, dont les « Détails des jeux » doivent être publics.
+STEAM_API_KEY=
+STEAM_ID=
+
 # --- Divers --------------------------------------------------------------
 # Fuseau horaire utilisé pour l'heure indiquée à Claude
 TIMEZONE=Europe/Paris

+ 1 - 0
.gitignore

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

+ 3 - 0
README.md

@@ -12,6 +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 (une bibliothèque Steam), 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.
 - **`/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.
@@ -60,6 +61,8 @@ bot/config.py         lecture et validation du .env
 bot/claude_client.py  appels à l'API de Claude
 bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
+bot/presence.py       statut Discord : tirage au sort et changements
+bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
 bot/text_files.py     lecture des fichiers .txt joints
 bot/text_utils.py     découpage des longues réponses
 ```

+ 1 - 1
bot/__init__.py

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

+ 64 - 0
bot/claude_client.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import base64
 import logging
+import re
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -63,6 +64,34 @@ WEB_NOTE = (
     "Ne prétends donc jamais avoir inventé une réponse marquée ainsi."
 )
 
+# Ajouté quand PRESENCE=on : le statut Discord en cours est indiqué au début des messages.
+PRESENCE_NOTE = (
+    "\n\nAu début d'un message, « [ton statut Discord : …] » indique ce que tu fais à ce moment-là "
+    "(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é."
+)
+
+# Demandes de statut personnalisé (pensée du moment, raison d'une absence), écrit avec la
+# personnalité du bot. Les clés sont les noms des statuts de presence.py.
+CUSTOM_STATUS_REQUESTS = {
+    "pensee": "Écris le statut personnalisé que tu affiches en ce moment sous ton pseudo Discord : "
+              "une courte pensée du moment, dans ton style",
+    "absent": "Tu t'absentes de ton PC pendant {duration}. Écris le statut personnalisé que tu affiches "
+              "sous ton pseudo Discord pendant ce temps : une raison plausible, dans ton style",
+    "ne_pas_deranger": "Tu passes en « Ne pas déranger » sur Discord pendant {duration}. Écris le statut "
+                       "personnalisé que tu affiches sous ton pseudo pour expliquer pourquoi : une raison "
+                       "plausible, dans ton style",
+}
+CUSTOM_STATUS_PROMPT = (
+    "Nous sommes {now}. {request}, de 60 caractères au plus. Les emojis du serveur ne s'affichent pas "
+    "dans un statut : si tu veux un emoji, prends un emoji classique (Unicode).{recent} "
+    "Réponds uniquement par ce statut, sans guillemets ni explication."
+)
+
+CUSTOM_EMOJI = re.compile(r"<a?:\w+:\d+>")
+
 # Nombre maximal de relances quand l'API met la réponse en pause pendant les recherches.
 MAX_CONTINUATIONS = 3
 
@@ -152,6 +181,8 @@ class ClaudeClient:
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         if self.settings.web_search:
             system += WEB_NOTE
+        if self.settings.presence:
+            system += PRESENCE_NOTE
 
         # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
         # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
@@ -204,6 +235,39 @@ class ClaudeClient:
             raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
         return Answer(text, web_used)
 
+    async def write_custom_status(self, kind: str, now: str, duration: str, recent: list[str],
+                                  max_length: int) -> str | None:
+        """Statut personnalisé du bot (voir CUSTOM_STATUS_REQUESTS), ou None en cas d'échec."""
+        recent_part = ""
+        if recent:
+            recent_part = " Ne reprends pas tes statuts récents : " + ", ".join(f"« {r} »" for r in recent) + "."
+        prompt = CUSTOM_STATUS_PROMPT.format(
+            now=now, request=CUSTOM_STATUS_REQUESTS[kind].format(duration=duration), recent=recent_part,
+        )
+        params: dict = {
+            "model": self.settings.model,
+            "max_tokens": 1000,
+            "system": self.personality() + OUTPUT_NOTE,
+            "messages": [{"role": "user", "content": prompt}],
+            # Une phrase courte : inutile de réfléchir longtemps.
+            "extra_body": {"output_config": {"effort": "low"}},
+        }
+        if (off := thinking_off(self.settings.model)) is not None:
+            params["thinking"] = off
+        try:
+            response = await self._call(params, has_image=False)
+        except ClaudeError:
+            return None
+        self._log_usage([response])
+        lines = [line.strip() for line in self._final_text([response]).splitlines() if line.strip()]
+        # Un emoji du serveur s'afficherait sous forme de code (<:nom:123…>) : on le retire.
+        thought = CUSTOM_EMOJI.sub("", lines[0]).strip("\"'«» ") if lines else ""
+        thought = re.sub(r"\s{2,}", " ", thought)
+        if not thought or "<" in thought:  # vide, ou balise interne écrite par erreur
+            logger.warning("Statut personnalisé inutilisable : %r", thought)
+            return None
+        return thought[:max_length]
+
     async def _call(self, params: dict, has_image: bool):
         try:
             return await self._client.messages.create(**params)

+ 75 - 0
bot/config.py

@@ -13,6 +13,13 @@ from .models import PRICES, thinking_off
 
 EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
 
+# Chances (relatives) de chaque statut Discord lors d'un changement de statut.
+DEFAULT_PRESENCE_WEIGHTS = {
+    "anime": 20, "musique": 20, "pensee": 10, "jeu": 20, "en_ligne": 20, "ne_pas_deranger": 5, "absent": 5,
+}
+# Tops Deezer officiels : Top Worldwide, Top France, Top Japan.
+DEFAULT_DEEZER_PLAYLISTS = "3155776842,1109890291,1362508955"
+
 
 class ConfigError(Exception):
     """Erreur de configuration (variable manquante ou invalide)."""
@@ -50,6 +57,51 @@ def _get_float(name: str, default: float | None = None) -> float | None:
         raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
 
 
+def _get_presence_weights() -> dict[str, int]:
+    """PRESENCE_WEIGHTS, par exemple « anime=20,musique=20,… » ; les statuts absents gardent leur valeur par défaut."""
+    weights = dict(DEFAULT_PRESENCE_WEIGHTS)
+    raw = _get_str("PRESENCE_WEIGHTS")
+    if raw is None:
+        return weights
+    for item in raw.split(","):
+        name, _, value = item.partition("=")
+        name = name.strip().lower()
+        if name not in weights:
+            raise ConfigError(f"Statut inconnu dans PRESENCE_WEIGHTS : {name!r} (statuts possibles : {', '.join(weights)}).")
+        try:
+            weights[name] = int(value)
+        except ValueError:
+            raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} doit être un nombre entier (valeur actuelle : {value.strip()!r}).")
+        if weights[name] < 0:
+            raise ConfigError(f"PRESENCE_WEIGHTS : la valeur de {name} ne peut pas être négative.")
+    if sum(weights.values()) == 0:
+        raise ConfigError("PRESENCE_WEIGHTS : au moins un statut doit avoir une valeur supérieure à 0.")
+    return weights
+
+
+def _get_night() -> tuple[int, int] | None:
+    """PRESENCE_NIGHT, par exemple « 2-6 » (de 2 h à 6 h) ; « off » = pas de nuit."""
+    raw = _get_str("PRESENCE_NIGHT", default="2-6")
+    if raw.lower() == "off":
+        return None
+    start, _, end = raw.partition("-")
+    try:
+        hours = (int(start), int(end))
+    except ValueError:
+        hours = None
+    if hours is None or not all(0 <= h <= 23 for h in hours) or hours[0] == hours[1]:
+        raise ConfigError(f"PRESENCE_NIGHT doit être de la forme « 2-6 » (heures de 0 à 23) ou « off » (valeur actuelle : {raw!r}).")
+    return hours
+
+
+def _get_deezer_playlists() -> tuple[int, ...]:
+    raw = _get_str("DEEZER_PLAYLISTS", default=DEFAULT_DEEZER_PLAYLISTS)
+    try:
+        return tuple(int(p) for p in raw.split(",") if p.strip())
+    except ValueError:
+        raise ConfigError(f"DEEZER_PLAYLISTS doit être une liste de numéros séparés par des virgules (valeur actuelle : {raw!r}).")
+
+
 @dataclass(frozen=True)
 class Settings:
     # Identifiants
@@ -77,6 +129,15 @@ class Settings:
     # Images
     max_image_size: int
 
+    # Statut Discord (voir presence.py)
+    presence: bool
+    presence_speed: float                 # pour tester : 10 = statuts 10 fois plus courts
+    presence_weights: dict[str, int]
+    presence_night: tuple[int, int] | None  # heures de début et de fin ; None = pas de nuit
+    deezer_playlists: tuple[int, ...]
+    steam_api_key: str | None
+    steam_id: str | None
+
     # Divers
     timezone: ZoneInfo
     price_input_per_mtok: float | None   # None = modèle absent de la table des tarifs
@@ -118,6 +179,13 @@ def load_settings() -> Settings:
     if web_search_raw not in ("on", "off"):
         raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
 
+    presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
+    if presence_raw not in ("on", "off"):
+        raise ConfigError(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
+    presence_speed = _get_float("PRESENCE_SPEED", default=1.0)
+    if presence_speed <= 0:
+        raise ConfigError(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
+
     return Settings(
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
@@ -135,6 +203,13 @@ def load_settings() -> Settings:
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
+        presence=presence_raw == "on",
+        presence_speed=presence_speed,
+        presence_weights=_get_presence_weights(),
+        presence_night=_get_night(),
+        deezer_playlists=_get_deezer_playlists(),
+        steam_api_key=_get_str("STEAM_API_KEY"),
+        steam_id=_get_str("STEAM_ID"),
         timezone=timezone,
         price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=default_input),
         price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=default_output),

+ 21 - 4
bot/discord_bot.py

@@ -17,6 +17,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 .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
 
@@ -75,6 +76,7 @@ class ChatBot(commands.Bot):
         self.history = ConversationHistory(settings.history_file, settings.max_history_length)
         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
 
     # ------------------------------------------------------------ cycle de vie
 
@@ -87,9 +89,13 @@ class ChatBot(commands.Bot):
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s",
                     s.model, "on" if s.thinking else "off", s.effort or "par défaut",
                     "on" if s.web_search else "off")
+        if self.presence:
+            self.presence.start()
 
     async def on_ready(self) -> None:
         logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
+        if self.presence:
+            await self.presence.apply()  # une reconnexion complète efface le statut
         if self._announced:
             return  # simple reconnexion : on ne reposte pas le message de démarrage
         self._announced = True
@@ -111,6 +117,8 @@ class ChatBot(commands.Bot):
             logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
 
     async def close(self) -> None:
+        if self.presence:
+            self.presence.stop()
         await self.claude.close()
         await super().close()
 
@@ -178,11 +186,15 @@ class ChatBot(commands.Bot):
             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)
+            # 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]
+            if note := self.presence_note():
+                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), [(p.user_text, p.image) for p in batch]
-                    )
+                    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
@@ -190,7 +202,7 @@ class ChatBot(commands.Bot):
                     logger.exception("Erreur inattendue pendant le traitement d'un message")
                     await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
                     return
-            stored_text = "\n".join(p.stored_text 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)
@@ -229,6 +241,8 @@ class ChatBot(commands.Bot):
         channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
 
         parts = []
+        if note := self.presence_note():
+            parts.append(note)
         if lines:
             parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
         reply_part = f" ({reply})" if reply else ""
@@ -443,6 +457,9 @@ class ChatBot(commands.Bot):
 
     # ------------------------------------------------------------- envoi
 
+    def presence_note(self) -> str | None:
+        return self.presence.prompt_note() if self.presence else None
+
     def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
         return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
 

+ 316 - 0
bot/presence.py

@@ -0,0 +1,316 @@
+"""Statut Discord du bot : l'anime qu'il regarde, la musique qu'il écoute, le jeu auquel il joue…
+
+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.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+from collections import deque
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import TYPE_CHECKING
+
+import discord
+
+from .presence_sources import PresenceSources, Track
+
+if TYPE_CHECKING:
+    from discord.ext import commands
+
+    from .claude_client import ClaudeClient
+    from .config import Settings
+
+logger = logging.getLogger("chatbot.presence")
+
+UTC = timezone.utc  # datetime.UTC n'existe qu'à partir de Python 3.11
+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
+
+# Durée de chaque statut, tirée au sort entre ces bornes.
+ANIME_EPISODES = (1, 5)
+MUSIC_TRACKS = (6, 20)
+DEFAULT_ANIME_EPISODES = 12  # épisodes disponibles quand AniList ne le précise pas
+MINUTES = {  # en minutes
+    "jeu": (45, 120),
+    "en_ligne": (15, 45),
+    "pensee": (30, 90),
+    "absent": (10, 60),
+    "ne_pas_deranger": (30, 90),
+}
+
+WEEKDAYS = ("lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche")
+
+
+class Kind(Enum):
+    # Les valeurs sont les noms utilisés dans PRESENCE_WEIGHTS.
+    ANIME = "anime"
+    MUSIC = "musique"
+    THOUGHT = "pensee"
+    GAME = "jeu"
+    ONLINE = "en_ligne"
+    DND = "ne_pas_deranger"
+    IDLE = "absent"
+    NIGHT = "nuit"  # absent automatiquement pendant PRESENCE_NIGHT, jamais tiré au sort
+
+
+@dataclass(frozen=True)
+class Status:
+    kind: Kind
+    title: str | None = None    # anime, jeu, pensée, ou raison d'une absence
+    track: Track | None = None  # musique en cours
+    episode: int | None = None  # épisode en cours (None pour un film)
+
+    def activity(self) -> discord.BaseActivity | None:
+        if self.kind is Kind.ANIME:
+            state = f"Épisode {self.episode}" if self.episode else None
+            return discord.Activity(type=discord.ActivityType.watching, name=self.title, state=state)
+        if self.kind is Kind.MUSIC:
+            return discord.Activity(type=discord.ActivityType.listening,
+                                    name=f"{self.track.title} de {self.track.artist}")
+        if self.kind is Kind.GAME:
+            return discord.Game(name=self.title)
+        if self.title:  # pensée, ou raison d'une absence
+            return discord.CustomActivity(name=self.title)
+        return None
+
+    def discord_status(self) -> discord.Status:
+        if self.kind is Kind.DND:
+            return discord.Status.dnd
+        if self.kind in (Kind.IDLE, Kind.NIGHT):
+            return discord.Status.idle
+        return discord.Status.online
+
+    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 ""
+        if self.kind is Kind.ANIME:
+            episode = f"l'épisode {self.episode} de " if self.episode else ""
+            return f"tu regardes {episode}l'anime « {self.title} »"
+        if self.kind is Kind.MUSIC:
+            return f"tu écoutes « {self.track.title} » de {self.track.artist}"
+        if self.kind is Kind.GAME:
+            return f"tu joues à « {self.title} » sur Steam"
+        if self.kind is Kind.THOUGHT:
+            return f"tu affiches le statut personnalisé « {self.title} »"
+        if self.kind is Kind.DND:
+            return f"tu es en « Ne pas déranger »{reason}"
+        if self.kind is Kind.IDLE:
+            return f"tu es absente{reason}"
+        if self.kind is Kind.NIGHT:
+            return "tu es absente pour la nuit"
+        return None
+
+
+# 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.
+Step = tuple[Status, float]
+
+
+def format_duration(seconds: float) -> str:
+    minutes = max(1, round(seconds / 60))
+    if minutes < 60:
+        return f"{minutes} min"
+    return f"{minutes // 60} h {minutes % 60:02d}" if minutes % 60 else f"{minutes // 60} h"
+
+
+class PresenceManager:
+    def __init__(self, bot: commands.Bot, claude: ClaudeClient, settings: Settings):
+        self.bot = bot
+        self.claude = claude
+        self.settings = settings
+        self.weights = {Kind(name): weight for name, weight in settings.presence_weights.items()}
+        self.sources = PresenceSources(
+            settings.history_file.with_name("presence_sources.json"),
+            settings.deezer_playlists, settings.steam_api_key, settings.steam_id,
+        )
+        self.status = Status(Kind.ONLINE)
+        self.program: list[Step] = []  # étapes restantes du statut en cours
+        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._tasks: list[asyncio.Task] = []
+
+    def start(self) -> None:
+        if not self.sources.steam_enabled:
+            logger.info("STEAM_API_KEY ou STEAM_ID absent : pas de statut « joue à ».")
+        self._tasks.append(asyncio.create_task(self._run()))
+
+    def stop(self) -> None:
+        for task in self._tasks:
+            task.cancel()
+
+    def prompt_note(self) -> str | None:
+        """Ligne ajoutée au début du message envoyé à Claude."""
+        description = self.status.describe()
+        if not description:
+            return None
+        if self.status_end and self.status.kind is not Kind.NIGHT:
+            now = datetime.now(UTC)
+            speed = self.settings.presence_speed
+            total = format_duration((self.status_end - now).total_seconds() * speed)
+            if not self.program:
+                if self.status.kind is Kind.ANIME and self.status.episode:
+                    description += " (le dernier épisode que tu comptes regarder)"
+                elif self.status.kind is Kind.MUSIC:
+                    description += " (le dernier morceau que tu comptes écouter)"
+                description += f", encore pendant environ {total}"
+            else:
+                # Anime ou musique : l'épisode (ou le morceau) en cours n'est qu'une partie du programme.
+                current = format_duration((self.step_end - now).total_seconds() * speed)
+                if self.status.kind is Kind.ANIME:
+                    description += (f" (fin de l'épisode dans environ {current}) ; tu comptes regarder "
+                                    f"jusqu'à l'épisode {self.program[-1][0].episode}, encore environ {total} en tout")
+                else:
+                    description += (f" (fin du morceau dans environ {current}) ; encore {len(self.program)} "
+                                    f"morceau(x) après celui-ci, environ {total} de musique en tout")
+        return f"[ton statut Discord : {description}]"
+
+    async def apply(self) -> None:
+        """Envoie le statut à Discord (aussi rappelé après une reconnexion, qui l'efface)."""
+        try:
+            await self.bot.change_presence(activity=self.status.activity(), status=self.status.discord_status())
+        except Exception as exc:  # déconnecté : on_ready le renverra
+            logger.warning("Statut Discord non appliqué : %s", exc)
+
+    # ------------------------------------------------------------ boucles
+
+    async def _run(self) -> None:
+        self.sources.load_cache()
+        await self.sources.refresh()
+        self._tasks.append(asyncio.create_task(self._refresh_loop()))
+        await self.bot.wait_until_ready()
+        while True:
+            try:
+                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()))
+
+    async def _refresh_loop(self) -> None:
+        while True:
+            await asyncio.sleep(SOURCES_REFRESH_INTERVAL)
+            try:
+                await self.sources.refresh()
+            except Exception:
+                logger.exception("Erreur inattendue pendant la mise à jour des listes de statuts")
+
+    async def _step(self) -> datetime:
+        """Passe à l'étape suivante quand la précédente est finie ; renvoie l'heure du prochain réveil."""
+        night = self.settings.presence_night
+        now = datetime.now(UTC)
+        local = now.astimezone(self.settings.timezone)
+
+        if self.is_night(local):
+            if self.status.kind is not Kind.NIGHT:
+                # Coupure nette : le statut en cours s'arrête, un nouveau sera tiré au réveil.
+                self.program = []
+                self.status_end = None
+                await self.set_status(Status(Kind.NIGHT))
+            self.step_end = self.next_hour(local, night[1])
+            return self.step_end
+
+        if now >= self.step_end:
+            if not self.program:
+                self.program = await self.draw()
+                total = sum(seconds for _, seconds in self.program)
+                self.status_end = now + timedelta(seconds=total)
+                logger.info("Nouveau statut pour %s.", format_duration(total * self.settings.presence_speed))
+            status, seconds = self.program.pop(0)
+            self.step_end = now + timedelta(seconds=seconds)
+            await self.set_status(status)
+
+        if night:
+            return min(self.step_end, self.next_hour(local, night[0]))
+        return self.step_end
+
+    # ------------------------------------------------------------ tirage
+
+    async def draw(self) -> list[Step]:
+        """Tire un nouveau statut et son programme (durées divisées par PRESENCE_SPEED)."""
+        available = {kind: weight for kind, weight in self.weights.items() if weight > 0 and self.has_source(kind)}
+        # Jamais deux fois le même statut à la suite, sauf s'il n'y a pas d'autre choix.
+        if len(available) > 1:
+            available.pop(self.status.kind, None)
+        kind = random.choices(list(available), weights=list(available.values()))[0] if available else Kind.ONLINE
+        speed = self.settings.presence_speed
+
+        if kind is Kind.ANIME:
+            anime = self.pick(self.sources.anime, lambda a: a.title == self.status.title)
+            if anime.episodes == 1:  # film
+                return [(Status(kind, title=anime.title), anime.duration * 60 / speed)]
+            available_episodes = anime.episodes or DEFAULT_ANIME_EPISODES
+            count = min(random.randint(*ANIME_EPISODES), available_episodes)
+            first = random.randint(1, available_episodes - count + 1)
+            return [(Status(kind, title=anime.title, episode=n), anime.duration * 60 / speed)
+                    for n in range(first, first + count)]
+
+        if kind is Kind.MUSIC:
+            count = min(random.randint(*MUSIC_TRACKS), len(self.sources.music))
+            tracks = random.sample(self.sources.music, count)
+            return [(Status(kind, track=t), t.duration / speed) for t in tracks]
+
+        seconds = random.randint(*MINUTES[kind.value]) * 60 if kind.value in MINUTES else 0
+        if kind is Kind.GAME:
+            title = self.pick(self.sources.games, lambda g: g == self.status.title)
+            return [(Status(kind, title=title), seconds / speed)]
+        if kind in (Kind.THOUGHT, Kind.IDLE, Kind.DND):
+            text = await self.write_custom_status(kind, seconds)
+            if kind is Kind.THOUGHT and not text:
+                kind = Kind.ONLINE  # pensée impossible à écrire : en ligne, sans activité
+            return [(Status(kind, title=text), seconds / speed)]
+        return [(Status(kind), seconds / speed)]
+
+    async def write_custom_status(self, kind: Kind, seconds: int) -> str | None:
+        local = datetime.now(self.settings.timezone)
+        now = f"{WEEKDAYS[local.weekday()]} {local.strftime('%d/%m/%Y, %H:%M')}"
+        text = await self.claude.write_custom_status(
+            kind.value, now, format_duration(seconds), list(self.recent_custom), CUSTOM_STATUS_MAX_LENGTH,
+        )
+        if text:
+            self.recent_custom.append(text)
+        return text
+
+    def has_source(self, kind: Kind) -> bool:
+        if kind is Kind.ANIME:
+            return bool(self.sources.anime)
+        if kind is Kind.MUSIC:
+            return bool(self.sources.music)
+        if kind is Kind.GAME:
+            return bool(self.sources.games)
+        return True
+
+    @staticmethod
+    def pick(items: list, is_current):
+        """Élément au hasard, différent de l'actuel quand c'est possible."""
+        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
+        logger.info("Statut Discord : %s", status.describe() or "en ligne, sans activité")
+        await self.apply()
+
+    # ------------------------------------------------------------ nuit
+
+    def is_night(self, local: datetime) -> bool:
+        if not self.settings.presence_night:
+            return False
+        start, end = self.settings.presence_night
+        if start < end:
+            return start <= local.hour < end
+        return local.hour >= start or local.hour < end  # nuit à cheval sur minuit (ex. 23-6)
+
+    @staticmethod
+    def next_hour(local: datetime, hour: int) -> datetime:
+        """Prochain passage à hour:00, en UTC."""
+        candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0)
+        if candidate <= local:
+            candidate += timedelta(days=1)
+        return candidate.astimezone(UTC)

+ 160 - 0
bot/presence_sources.py

@@ -0,0 +1,160 @@
+"""Animes, musiques et jeux réels pour le statut Discord, lus sur AniList, Deezer et Steam."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import aiohttp
+
+logger = logging.getLogger("chatbot.presence")
+
+ANILIST_URL = "https://graphql.anilist.co"
+ANILIST_QUERY = """
+query ($page: Int) {
+  Page(page: $page, perPage: 50) {
+    media(type: ANIME, sort: POPULARITY_DESC, isAdult: false) {
+      title { romaji } episodes duration nextAiringEpisode { episode }
+    }
+  }
+}
+"""
+ANIME_PAGES = 10  # 10 pages de 50 : le top 500 des animes les plus populaires
+# Les tops Deezer contiennent 100 titres.
+DEEZER_TRACKS_URL = "https://api.deezer.com/playlist/{}/tracks?limit=100"
+STEAM_URL = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
+DEFAULT_TRACK_DURATION = 180  # secondes, si Deezer n'indique pas la durée
+DEFAULT_EPISODE_DURATION = 24  # minutes, si AniList n'indique pas la durée
+TIMEOUT = aiohttp.ClientTimeout(total=30)
+
+
+class SourceError(Exception):
+    """Lecture d'une source impossible. Le message ne contient jamais la clé Steam."""
+
+
+@dataclass(frozen=True)
+class Anime:
+    title: str
+    episodes: int | None  # épisodes déjà sortis (None : inconnu) ; 1 pour un film
+    duration: int         # minutes par épisode
+
+
+@dataclass(frozen=True)
+class Track:
+    title: str
+    artist: str
+    duration: int  # secondes
+
+
+async def get_json(session: aiohttp.ClientSession, method: str, url: str, **kwargs) -> dict:
+    try:
+        async with session.request(method, url, **kwargs) as response:
+            if response.status in (401, 403):
+                raise SourceError(f"accès refusé (HTTP {response.status})")
+            if response.status != 200:
+                raise SourceError(f"réponse HTTP {response.status}")
+            return await response.json(content_type=None)
+    except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
+        # Pas de str(exc) : l'adresse de la requête, qu'il peut contenir, porte la clé Steam.
+        raise SourceError(type(exc).__name__) from None
+
+
+class PresenceSources:
+    """Listes téléchargées au démarrage puis une fois par jour, gardées en cache sur le disque.
+
+    Si une source ne répond pas, sa liste précédente (ou celle du cache) est conservée."""
+
+    def __init__(self, cache_file: Path, deezer_playlists: tuple[int, ...],
+                 steam_api_key: str | None, steam_id: str | None):
+        self.cache_file = cache_file
+        self.deezer_playlists = deezer_playlists
+        self.steam_api_key = steam_api_key
+        self.steam_id = steam_id
+        self.anime: list[Anime] = []
+        self.music: list[Track] = []
+        self.games: list[str] = []
+
+    @property
+    def steam_enabled(self) -> bool:
+        return bool(self.steam_api_key and self.steam_id)
+
+    def load_cache(self) -> None:
+        try:
+            data = json.loads(self.cache_file.read_text(encoding="utf-8"))
+            self.anime = [Anime(**a) for a in data.get("anime", [])]
+            self.music = [Track(**t) for t in data.get("music", [])]
+            if self.steam_enabled:
+                self.games = list(data.get("games", []))
+        except FileNotFoundError:
+            pass
+        except (OSError, ValueError, TypeError) as exc:
+            logger.warning("Cache des statuts %s illisible, ignoré : %s", self.cache_file, exc)
+
+    def save_cache(self) -> None:
+        data = {"anime": [asdict(a) for a in self.anime], "music": [asdict(t) for t in self.music], "games": self.games}
+        try:
+            self.cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
+        except OSError as exc:
+            logger.warning("Impossible d'écrire le cache des statuts %s : %s", self.cache_file, exc)
+
+    async def refresh(self) -> None:
+        sources = [("anime", "Animes (AniList)", self.fetch_anime), ("music", "Musiques (Deezer)", self.fetch_music)]
+        if self.steam_enabled:
+            sources.append(("games", "Jeux (Steam)", self.fetch_games))
+        async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
+            for attr, label, fetch in sources:
+                try:
+                    items = await fetch(session)
+                    if not items:
+                        raise SourceError("liste vide")
+                except SourceError as exc:
+                    logger.warning("%s : lecture impossible (%s), %d élément(s) conservé(s).",
+                                   label, exc, len(getattr(self, attr)))
+                else:
+                    setattr(self, attr, items)
+                    logger.info("%s : %d élément(s).", label, len(items))
+        self.save_cache()
+
+    @staticmethod
+    async def fetch_anime(session: aiohttp.ClientSession) -> list[Anime]:
+        anime: dict[str, Anime] = {}
+        for page in range(1, ANIME_PAGES + 1):
+            data = await get_json(session, "POST", ANILIST_URL,
+                                  json={"query": ANILIST_QUERY, "variables": {"page": page}})
+            try:
+                media = data["data"]["Page"]["media"]
+            except (KeyError, TypeError):
+                raise SourceError("réponse inattendue") from None
+            for m in media:
+                title = (m.get("title") or {}).get("romaji")
+                if not title:
+                    continue
+                # Anime en cours de diffusion : seuls les épisodes déjà sortis comptent.
+                episodes = m.get("episodes")
+                if episodes is None and m.get("nextAiringEpisode"):
+                    episodes = m["nextAiringEpisode"]["episode"] - 1 or None
+                anime[title] = Anime(title, episodes, m.get("duration") or DEFAULT_EPISODE_DURATION)
+        return list(anime.values())
+
+    async def fetch_music(self, session: aiohttp.ClientSession) -> list[Track]:
+        tracks: dict[int, Track] = {}  # par identifiant Deezer : un titre présent dans deux tops ne compte qu'une fois
+        for playlist in self.deezer_playlists:
+            data = await get_json(session, "GET", DEEZER_TRACKS_URL.format(playlist))
+            if "error" in data:  # Deezer signale ses erreurs avec un code HTTP 200
+                raise SourceError(f"playlist {playlist} : {data['error'].get('message', 'erreur')}")
+            for t in data.get("data", []):
+                if t.get("id") and t.get("title") and t.get("artist", {}).get("name"):
+                    tracks[t["id"]] = Track(t["title"], t["artist"]["name"], t.get("duration") or DEFAULT_TRACK_DURATION)
+        return list(tracks.values())
+
+    async def fetch_games(self, session: aiohttp.ClientSession) -> list[str]:
+        params = {"key": self.steam_api_key, "steamid": self.steam_id, "include_appinfo": "1",
+                  "include_played_free_games": "1", "format": "json"}
+        data = await get_json(session, "GET", STEAM_URL, params=params)
+        games = data.get("response", {}).get("games")
+        if not games:
+            raise SourceError("bibliothèque vide ou privée : « Détails des jeux » doit être public dans le profil Steam")
+        return sorted({g["name"] for g in games if g.get("name")})

+ 37 - 0
changelog.txt

@@ -2,6 +2,43 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.6 — 30/10/2026
+------------------------
+
+Nouveautés
+- Statut Discord : sous son pseudo, Rika regarde un anime, écoute de la
+  musique, joue à un jeu, affiche une pensée du moment, ou passe en « Ne pas
+  déranger » ou absente.
+  - Chances de chaque statut : anime 20 %, musique 20 %, pensée 10 %, jeu
+    20 %, en ligne sans activité 20 %, Ne pas déranger 5 %, absente 5 %
+    (PRESENCE_WEIGHTS dans le .env). Chaque statut a sa propre durée, tirée
+    au sort ; à la fin, un nouveau statut est tiré, jamais le même que le
+    précédent.
+  - Animes : un titre (romaji) au hasard dans le top 500 d'AniList. Rika
+    regarde 1 à 5 épisodes à la suite, à partir d'un épisode précis, à la
+    durée réelle d'un épisode (un film compte pour un épisode).
+  - Musiques : 6 à 20 titres au hasard dans le Top Worldwide, le Top France
+    et le Top Japan de Deezer, chacun à sa durée réelle.
+  - Jeux : un jeu au hasard dans la bibliothèque Steam indiquée par
+    STEAM_API_KEY et STEAM_ID (sans eux, pas de statut « joue à »), pendant
+    45 min à 2 h.
+  - En ligne sans activité : 15 à 45 min.
+  - Pensée du moment (30 à 90 min), raison d'une absence (10 à 60 min) ou
+    d'un Ne pas déranger (30 à 90 min) : écrites par Claude avec la
+    personnalité du bot, selon l'heure et la durée, sans reprendre les
+    dernières.
+  - De 2 h à 6 h, Rika est automatiquement absente (PRESENCE_NIGHT), quoi
+    qu'elle soit en train de faire.
+  - Quand on lui parle, Rika sait ce qu'elle est en train de faire, et pour
+    combien de temps encore.
+  - PRESENCE_SPEED=10 rend tous les statuts 10 fois plus courts, pour tester.
+  - 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.
+
+
 Version 5.5 — 29/10/2026
 ------------------------