浏览代码

Statut Discord : jeux tirés dans toute la boutique Steam

Les jeux ne viennent plus d'une bibliothèque Steam mais de toute la
boutique (IStoreService/GetAppList), sans DLC ni logiciels. La fiche du
jeu tiré est lue sur la boutique : les jeux pour adultes sont écartés,
et Rika connaît la présentation du jeu. STEAM_ID n'est plus utilisé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 天之前
父节点
当前提交
482aa98a29
共有 6 个文件被更改,包括 85 次插入 和 30 次删除
  1. 1 3
      .env.exemple
  2. 1 1
      README.md
  3. 0 2
      bot/config.py
  4. 24 6
      bot/presence.py
  5. 55 15
      bot/presence_sources.py
  6. 4 3
      changelog.txt

+ 1 - 3
.env.exemple

@@ -56,11 +56,9 @@ PRESENCE_NIGHT=2-6
 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é pour piocher les jeux dans toute la boutique Steam. Sans elle, 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

+ 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 (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.
+- **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.
 - **`/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.

+ 0 - 2
bot/config.py

@@ -136,7 +136,6 @@ class Settings:
     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
@@ -209,7 +208,6 @@ def load_settings() -> Settings:
         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),

+ 24 - 6
bot/presence.py

@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING
 
 import discord
 
-from .presence_sources import PresenceSources, Track
+from .presence_sources import Game, PresenceSources, SourceError, Track
 
 if TYPE_CHECKING:
     from discord.ext import commands
@@ -32,6 +32,7 @@ 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
+GAME_ATTEMPTS = 5  # jeux essayés avant d'abandonner (fiche introuvable, jeu pour adultes…)
 
 # Durée de chaque statut, tirée au sort entre ces bornes.
 ANIME_EPISODES = (1, 5)
@@ -66,6 +67,7 @@ class Status:
     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)
+    details: str | None = None  # présentation du jeu, pour Claude uniquement
 
     def activity(self) -> discord.BaseActivity | None:
         if self.kind is Kind.ANIME:
@@ -96,7 +98,8 @@ class Status:
         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"
+            details = f" (présentation du jeu sur Steam : {self.details})" if self.details else ""
+            return f"tu joues à « {self.title} » sur Steam{details}"
         if self.kind is Kind.THOUGHT:
             return f"tu affiches le statut personnalisé « {self.title} »"
         if self.kind is Kind.DND:
@@ -128,7 +131,7 @@ class PresenceManager:
         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,
+            settings.deezer_playlists, settings.steam_api_key,
         )
         self.status = Status(Kind.ONLINE)
         self.program: list[Step] = []  # étapes restantes du statut en cours
@@ -139,7 +142,7 @@ class PresenceManager:
 
     def start(self) -> None:
         if not self.sources.steam_enabled:
-            logger.info("STEAM_API_KEY ou STEAM_ID absent : pas de statut « joue à ».")
+            logger.info("STEAM_API_KEY absent : pas de statut « joue à ».")
         self._tasks.append(asyncio.create_task(self._run()))
 
     def stop(self) -> None:
@@ -259,8 +262,10 @@ class PresenceManager:
 
         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)]
+            game = await self.pick_game()
+            if game is None:
+                return [(Status(Kind.ONLINE), seconds / speed)]
+            return [(Status(kind, title=game.name, details=game.description), 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:
@@ -268,6 +273,19 @@ class PresenceManager:
             return [(Status(kind, title=text), seconds / speed)]
         return [(Status(kind), seconds / speed)]
 
+    async def pick_game(self) -> Game | None:
+        """Jeu au hasard dans toute la boutique Steam, en écartant les jeux pour adultes."""
+        for appid in random.sample(self.sources.games, min(GAME_ATTEMPTS, len(self.sources.games))):
+            try:
+                game = await self.sources.game_details(appid)
+            except SourceError as exc:
+                logger.warning("Fiche du jeu Steam %s illisible : %s", appid, exc)
+                return None
+            if game:
+                return game
+            logger.info("Jeu Steam %s écarté (introuvable, pas un jeu ou pour adultes).", appid)
+        return None
+
     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')}"

+ 55 - 15
bot/presence_sources.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import asyncio
+import html
 import json
 import logging
 from dataclasses import asdict, dataclass
@@ -25,7 +26,14 @@ query ($page: Int) {
 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/"
+STEAM_APPS_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/"
+STEAM_PAGE_SIZE = 50_000  # maximum accepté par Steam
+STEAM_MAX_PAGES = 10
+STEAM_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
+# Descripteurs de contenu Steam écartés : 3 = contenu sexuel réservé aux adultes,
+# 4 = nudité ou contenu sexuel fréquents (1 = « un peu de nudité » reste accepté).
+STEAM_EXCLUDED_DESCRIPTORS = {3, 4}
+STEAM_DESCRIPTION_MAX_LENGTH = 300
 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)
@@ -42,6 +50,12 @@ class Anime:
     duration: int         # minutes par épisode
 
 
+@dataclass(frozen=True)
+class Game:
+    name: str
+    description: str | None  # présentation courte de la boutique Steam
+
+
 @dataclass(frozen=True)
 class Track:
     title: str
@@ -68,18 +82,17 @@ class PresenceSources:
     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):
+                 steam_api_key: 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] = []
+        self.games: list[int] = []  # identifiants de tous les jeux de la boutique Steam
 
     @property
     def steam_enabled(self) -> bool:
-        return bool(self.steam_api_key and self.steam_id)
+        return bool(self.steam_api_key)
 
     def load_cache(self) -> None:
         try:
@@ -87,7 +100,7 @@ class PresenceSources:
             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", []))
+                self.games = [g for g in data.get("games", []) if isinstance(g, int)]
         except FileNotFoundError:
             pass
         except (OSError, ValueError, TypeError) as exc:
@@ -96,7 +109,8 @@ class PresenceSources:
     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")
+            # Sans indentation : la liste des jeux compte plus de 100 000 identifiants.
+            self.cache_file.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
         except OSError as exc:
             logger.warning("Impossible d'écrire le cache des statuts %s : %s", self.cache_file, exc)
 
@@ -150,11 +164,37 @@ class PresenceSources:
                     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")})
+    async def fetch_games(self, session: aiohttp.ClientSession) -> list[int]:
+        """Identifiants de tous les jeux de la boutique (sans DLC, logiciels ni vidéos), page par page."""
+        games: list[int] = []
+        last_appid = 0
+        for _ in range(STEAM_MAX_PAGES):
+            params = {"key": self.steam_api_key, "include_games": "true", "include_dlc": "false",
+                      "include_software": "false", "include_videos": "false", "include_hardware": "false",
+                      "max_results": str(STEAM_PAGE_SIZE), "last_appid": str(last_appid)}
+            data = (await get_json(session, "GET", STEAM_APPS_URL, params=params)).get("response", {})
+            games += [app["appid"] for app in data.get("apps", []) if app.get("appid")]
+            if not data.get("have_more_results") or not data.get("last_appid"):
+                break
+            last_appid = data["last_appid"]
+        return games
+
+    @staticmethod
+    async def game_details(appid: int) -> Game | None:
+        """Fiche d'un jeu sur la boutique, ou None s'il ne convient pas (introuvable, pas un jeu, pour adultes)."""
+        async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
+            data = await get_json(session, "GET", STEAM_DETAILS_URL,
+                                  params={"appids": str(appid), "l": "french", "cc": "fr"})
+        entry = data.get(str(appid)) or {}
+        info = entry.get("data") if entry.get("success") else None
+        if not info or info.get("type") != "game" or not info.get("name"):
+            return None
+        descriptors = set((info.get("content_descriptors") or {}).get("ids") or [])
+        try:
+            adult = int(info.get("required_age") or 0) >= 18
+        except ValueError:
+            adult = False
+        if adult or descriptors & STEAM_EXCLUDED_DESCRIPTORS:
+            return None
+        description = html.unescape(info.get("short_description") or "").strip()
+        return Game(info["name"], description[:STEAM_DESCRIPTION_MAX_LENGTH] or None)

+ 4 - 3
changelog.txt

@@ -19,9 +19,10 @@ Nouveautés
     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.
+  - Jeux : un jeu au hasard dans toute la boutique Steam (clé STEAM_API_KEY ;
+    sans elle, pas de statut « joue à »), pendant 45 min à 2 h. Les jeux
+    pour adultes sont écartés, et Rika connaît la présentation du jeu sur la
+    boutique.
   - 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