Преглед изворни кода

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
 ## 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.
 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) ;
    - 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.
    - 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`) ;
    - 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** ;
    - 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.
    - 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 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
 ## Installation
 
 
 1. Ouvre un terminal dans le dossier du bot et crée un environnement virtuel :
 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
         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):
 class ChatBot(commands.Bot):
     def __init__(self, settings: Settings):
     def __init__(self, settings: Settings):
         intents = discord.Intents.default()
         intents = discord.Intents.default()
@@ -125,6 +141,7 @@ class ChatBot(commands.Bot):
             command_prefix=commands.when_mentioned,  # aucune commande textuelle utilisée
             command_prefix=commands.when_mentioned,  # aucune commande textuelle utilisée
             intents=intents,
             intents=intents,
             help_command=None,
             help_command=None,
+            tree_cls=HomeTree,
             # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
             # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
             allowed_mentions=discord.AllowedMentions.none(),
             allowed_mentions=discord.AllowedMentions.none(),
         )
         )
@@ -212,9 +229,13 @@ class ChatBot(commands.Bot):
 
 
         channel = self.get_channel(self.settings.channel_id)
         channel = self.get_channel(self.settings.channel_id)
         if channel is None:
         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
             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
         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.",
         logger.info("%d emoji(s) du serveur utilisable(s) dans les réponses.",
                     len(usable_emojis(getattr(channel, "guild", None))))
                     len(usable_emojis(getattr(channel, "guild", None))))
@@ -236,12 +257,25 @@ class ChatBot(commands.Bot):
         await self.claude.close()
         await self.claude.close()
         await super().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
     # ------------------------------------------------------------- messages
 
 
     async def on_message(self, message: discord.Message) -> None:
     async def on_message(self, message: discord.Message) -> None:
         # Ignore ses propres messages et les webhooks : évite les boucles infinies.
         # Ignore ses propres messages et les webhooks : évite les boucles infinies.
         if message.author == self.user or message.webhook_id is not None:
         if message.author == self.user or message.webhook_id is not None:
             return
             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:
         if message.type not in USER_MESSAGE_TYPES:
             return  # message système
             return  # message système
         # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
         # 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
   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
   au redémarrage du bot. Le réglage s'applique quand même, et la
   prochaine sauvegarde réussie du registre l'enregistre.
   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
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus
 - 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()