فهرست منبع

Bot limité au serveur de son salon : autres serveurs et messages privés ignorés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 روز پیش
والد
کامیت
124556a388
4فایلهای تغییر یافته به همراه137 افزوده شده و 5 حذف شده
  1. 7 3
      README.md
  2. 36 2
      bot/discord_bot.py
  3. 8 0
      changelog.txt
  4. 86 0
      tests/test_home_guild.py

+ 7 - 3
README.md

@@ -59,17 +59,21 @@ Il te faut :
 ## Créer le bot sur Discord
 
 1. Sur https://discord.com/developers/applications, crée une application (**New Application**) et donne-lui le nom du bot.
-2. Onglet **Bot** :
+2. Onglet **Installation** : dans **Installation Contexts**, décoche **User Install** (garde **Guild Install**), et choisis **None** comme **Install Link**.
+3. Onglet **Bot** :
+   - décoche **Public Bot** : sinon n'importe qui peut inviter le bot sur son serveur (Discord le refuse tant que l'Install Link de l'étape 2 n'est pas sur **None**) ;
    - clique sur **Reset Token** et copie le token : c'est le `DISCORD_TOKEN` (à garder secret) ;
    - dans **Privileged Gateway Intents**, active **Message Content Intent**. Les intents « Presence » et « Server Members » ne servent pas.
-3. Onglet **OAuth2 > URL Generator** :
+4. Onglet **OAuth2 > URL Generator** :
    - coche les scopes **bot** et **applications.commands** (ce dernier pour les commandes comme `/reset` et `/memoire`) ;
    - dans les permissions, coche **View Channels**, **Send Messages** et **Read Message History** ;
    - ouvre l'adresse générée et invite le bot sur ton serveur.
-4. Dans Discord, active le mode développeur (**Paramètres utilisateur > Avancés > Mode développeur**), puis fais un clic droit sur le salon du bot > **Copier l'identifiant du salon** : c'est le `CHANNEL_ID`.
+5. Dans Discord, active le mode développeur (**Paramètres utilisateur > Avancés > Mode développeur**), puis fais un clic droit sur le salon du bot > **Copier l'identifiant du salon** : c'est le `CHANNEL_ID`.
 
 Le bot n'a besoin d'aucune autre permission : il n'utilise que les emojis et stickers du serveur.
 
+Le bot ne sert qu'un serveur, celui du salon `CHANNEL_ID` : ailleurs (autre serveur, messages privés), il ignore les messages et refuse les commandes, puisque sa mémoire est commune. S'il a été ajouté à un autre serveur, le log le signale au démarrage.
+
 ## Installation
 
 1. Ouvre un terminal dans le dossier du bot et crée un environnement virtuel :

+ 36 - 2
bot/discord_bot.py

@@ -117,6 +117,22 @@ class PendingMessage:
         return render_line(line) + self.extra
 
 
+class HomeTree(app_commands.CommandTree):
+    """Commandes slash acceptées seulement sur le serveur du bot (voir ChatBot.home_guild_id) : un
+    administrateur d'un autre serveur où le bot a été invité ne peut ni lire ni modifier la mémoire."""
+
+    async def interaction_check(self, interaction: discord.Interaction) -> bool:
+        bot: ChatBot = self.client  # type: ignore[assignment]
+        if interaction.guild_id is not None and interaction.guild_id == bot.home_guild_id():
+            return True
+        logger.warning("/%s refusée pour %s : serveur %s, hors du serveur du bot",
+                       (interaction.data or {}).get("name", "?"), interaction.user, interaction.guild_id)
+        if interaction.type is discord.InteractionType.application_command:
+            await interaction.response.send_message("Ce bot ne fonctionne que sur son propre serveur.",
+                                                    ephemeral=True)
+        return False
+
+
 class ChatBot(commands.Bot):
     def __init__(self, settings: Settings):
         intents = discord.Intents.default()
@@ -125,6 +141,7 @@ class ChatBot(commands.Bot):
             command_prefix=commands.when_mentioned,  # aucune commande textuelle utilisée
             intents=intents,
             help_command=None,
+            tree_cls=HomeTree,
             # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
             allowed_mentions=discord.AllowedMentions.none(),
         )
@@ -212,9 +229,13 @@ class ChatBot(commands.Bot):
 
         channel = self.get_channel(self.settings.channel_id)
         if channel is None:
-            logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot.",
-                         self.settings.channel_id)
+            logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot. "
+                         "En attendant, le bot ne répond nulle part.", self.settings.channel_id)
             return
+        if others := [guild for guild in self.guilds if guild.id != self.home_guild_id()]:
+            logger.warning("Le bot est aussi sur %d autre(s) serveur(s), où il ne répond pas : %s. Retire-le de ces "
+                           "serveurs, et décoche Public Bot dans le portail Discord (voir le README).", len(others),
+                           ", ".join(f"{guild.name} ({guild.id})" for guild in others))
         name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
         logger.info("%d emoji(s) du serveur utilisable(s) dans les réponses.",
                     len(usable_emojis(getattr(channel, "guild", None))))
@@ -236,12 +257,25 @@ class ChatBot(commands.Bot):
         await self.claude.close()
         await super().close()
 
+    def home_guild_id(self) -> int | None:
+        """Le serveur du salon dédié (CHANNEL_ID), le seul où le bot répond : la mémoire n'existe qu'en
+        un exemplaire. None tant que le salon est introuvable (avant la connexion, CHANNEL_ID faux)."""
+        guild = getattr(self.get_channel(self.settings.channel_id), "guild", None)
+        return guild.id if guild else None
+
+    async def on_guild_join(self, guild: discord.Guild) -> None:
+        logger.warning("Bot ajouté au serveur %s (%s), où il ne répond pas : retire-le de ce serveur, et "
+                       "décoche Public Bot dans le portail Discord (voir le README).", guild.name, guild.id)
+
     # ------------------------------------------------------------- messages
 
     async def on_message(self, message: discord.Message) -> None:
         # Ignore ses propres messages et les webhooks : évite les boucles infinies.
         if message.author == self.user or message.webhook_id is not None:
             return
+        # Ni autre serveur ni message privé : réponses payées par le propriétaire, mémoire du serveur.
+        if message.guild is None or message.guild.id != self.home_guild_id():
+            return
         if message.type not in USER_MESSAGE_TYPES:
             return  # message système
         # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,

+ 8 - 0
changelog.txt

@@ -91,6 +91,14 @@ Corrections
   mémoire » le disent, au lieu d'annoncer un réglage qui serait perdu
   au redémarrage du bot. Le réglage s'applique quand même, et la
   prochaine sauvegarde réussie du registre l'enregistre.
+- Le bot ne sert plus que le serveur de son salon (CHANNEL_ID). Invité
+  sur un autre serveur, il ignore les messages et refuse les commandes :
+  un administrateur de ce serveur pouvait lire et réécrire la mémoire
+  du vrai serveur avec /memoire et /memoire-serveur, et les réponses
+  étaient payées par le propriétaire du bot. Les messages privés sont
+  aussi ignorés. Le log signale au démarrage les autres serveurs où se
+  trouve le bot, et le README explique comment décocher Public Bot et
+  User Install dans le portail Discord.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 86 - 0
tests/test_home_guild.py

@@ -0,0 +1,86 @@
+"""Tests du serveur du bot : rien n'est accepté ailleurs. Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+from unittest import mock
+
+import discord
+
+from bot.discord_bot import ChatBot, HomeTree
+
+HOME, OTHER = 1000, 2000
+CHANNEL_ID = 10
+
+
+def fake_bot(channel_found: bool = True) -> mock.Mock:
+    bot = mock.Mock()
+    bot.settings.channel_id = CHANNEL_ID
+    bot.get_channel.return_value = mock.Mock(guild=mock.Mock(id=HOME)) if channel_found else None
+    bot.home_guild_id = lambda: ChatBot.home_guild_id(bot)
+    bot.handle_main_channel = mock.AsyncMock()
+    bot.handle_mention = mock.AsyncMock()
+    bot.register_members = mock.AsyncMock()
+    bot.welcome = mock.AsyncMock()
+    return bot
+
+
+def fake_interaction(guild_id: int | None) -> mock.Mock:
+    interaction = mock.Mock(guild_id=guild_id, type=discord.InteractionType.application_command,
+                            data={"name": "memoire-serveur"})
+    interaction.response.send_message = mock.AsyncMock()
+    return interaction
+
+
+class CommandsTest(unittest.IsolatedAsyncioTestCase):
+    async def check(self, interaction: mock.Mock, channel_found: bool = True) -> bool:
+        tree = object.__new__(HomeTree)  # sans client Discord réel
+        tree.client = fake_bot(channel_found)
+        return await tree.interaction_check(interaction)
+
+    async def test_home_guild_accepted(self):
+        interaction = fake_interaction(HOME)
+        self.assertTrue(await self.check(interaction))
+        interaction.response.send_message.assert_not_awaited()
+
+    async def test_other_guild_refused(self):
+        # Administrateur d'un autre serveur où le bot a été invité : ni lecture ni écriture de la mémoire.
+        interaction = fake_interaction(OTHER)
+        self.assertFalse(await self.check(interaction))
+        interaction.response.send_message.assert_awaited_once()
+
+    async def test_direct_message_refused(self):
+        self.assertFalse(await self.check(fake_interaction(None)))
+
+    async def test_refused_while_channel_unknown(self):
+        self.assertFalse(await self.check(fake_interaction(HOME), channel_found=False))
+
+
+class MessagesTest(unittest.IsolatedAsyncioTestCase):
+    async def receive(self, guild: mock.Mock | None, channel_id: int = CHANNEL_ID) -> mock.Mock:
+        bot = fake_bot()
+        message = mock.Mock(guild=guild, webhook_id=None, type=discord.MessageType.default)
+        message.channel.id = channel_id
+        message.author.bot = False
+        message.mentions = [bot.user]
+        await ChatBot.on_message(bot, message)
+        return bot
+
+    async def test_home_guild_answered(self):
+        bot = await self.receive(mock.Mock(id=HOME))
+        bot.handle_main_channel.assert_awaited_once()
+
+    async def test_other_guild_ignored(self):
+        # Même un salon qui porte le même identifiant ou une mention : rien n'est lu ni noté.
+        bot = await self.receive(mock.Mock(id=OTHER))
+        bot.handle_main_channel.assert_not_awaited()
+        bot.handle_mention.assert_not_awaited()
+        bot.register_members.assert_not_awaited()
+        bot.welcome.assert_not_awaited()
+
+    async def test_direct_message_ignored(self):
+        bot = await self.receive(None, channel_id=99)
+        bot.handle_mention.assert_not_awaited()
+        bot.register_members.assert_not_awaited()
+
+
+if __name__ == "__main__":
+    unittest.main()