From bc9d5a8943f47ef20bb89afbe14d6b03b914a630 Mon Sep 17 00:00:00 2001
From: "Edgar P. Burkhart" <git@edgarpierre.fr>
Date: Sat, 22 Mar 2025 18:32:13 +0100
Subject: [PATCH] Update .gitignore and pyproject.toml; streamline main entry
 point for BotBotBot

---
 .gitignore            |  19 ++-
 botbotbot/__init__.py | 337 ++++++++++++++++++++++++++++++++++++++++
 botbotbot/__main__.py | 347 +-----------------------------------------
 pyproject.toml        |   3 +
 4 files changed, 356 insertions(+), 350 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7a7b50c..3931c59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,14 @@
-/env
-/assets
-/config.toml
-/wordlist.pickle
-__pycache__
+# Python-generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+
+# Virtual environments
+.venv
+
+# BotBotBot
+config.toml
+wordlist.pickle
diff --git a/botbotbot/__init__.py b/botbotbot/__init__.py
index e69de29..0b21a27 100644
--- a/botbotbot/__init__.py
+++ b/botbotbot/__init__.py
@@ -0,0 +1,337 @@
+import asyncio
+import logging
+import pickle
+import random
+import tomllib
+
+import discord
+
+from botbotbot.ai import AIBot
+
+
+def main() -> None:
+    description = """Discord Chaos Bot"""
+
+    logger = logging.getLogger(__name__)
+    logging.basicConfig(level=logging.INFO)
+
+    with open("config.toml", "rb") as config_file:
+        config = tomllib.load(config_file)
+
+    with open("wordlist.pickle", "rb") as word_file:
+        word_list = pickle.load(word_file)
+    guild_ids = config.get("guild_ids")
+    delay = config.get("delay", 60)
+
+    system_prompt = """Tu es une intelligence artificelle qui répond en français.
+    Tu dois faire un commentaire pertinent en lien avec ce qui te sera dit.
+    Ta réponse doit être très courte.
+    Ta réponse doit être une seule phrase.
+    TA RÉPONSE DOIT ÊTRE EN FRANÇAIS !!!"""
+
+    aibot: AIBot | None = None
+
+    if isinstance(key := config.get("mistral_api_key"), str):
+        aibot = AIBot(
+            key,
+            model="mistral-large-latest",
+            system_message=system_prompt,
+        )
+
+    intents = discord.Intents.default()
+    intents.members = True
+    intents.message_content = True
+    intents.reactions = True
+    intents.voice_states = True
+
+    bot = discord.Bot(description=description, intents=intents)
+
+    shuffle_tasks = set()
+
+    @bot.listen("on_ready")
+    async def on_ready() -> None:
+        logger.info(f"We have logged in as {bot.user}")
+
+    async def reply(message: discord.Message) -> None:
+        logger.info(f"Reply to {message.author}")
+        mention = random.choices(
+            [f"<@{message.author.id}>", "@everyone", "@here"], weights=(98, 1, 1)
+        )[0]
+        content = random.choice(
+            (
+                f"{mention}, {random.choice(word_list)}",
+                f"{random.choice(word_list)}",
+            )
+        )
+
+        if isinstance(message.channel, discord.TextChannel) and random.random() < 0.1:
+            await send_as_webhook(
+                message.channel,
+                message.author.display_name,
+                message.author.avatar.url if message.author.avatar else None,
+                content,
+            )
+        else:
+            fct = random.choice((message.reply, message.channel.send))
+
+            await fct(content)
+
+    async def ai_reply(message: discord.Message) -> None:
+        if aibot is None:
+            return
+
+        logger.info(f"AI Reply to {message.author}")
+        prompt = message.clean_content
+        if prompt == "" and message.embeds and message.embeds[0].description:
+            prompt = message.embeds[0].description
+
+        answer = aibot.answer(prompt)
+        if not isinstance(answer, str):
+            return
+
+        if len(answer) > 2000:
+            embed = discord.Embed(
+                description=answer,
+                thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png",
+            )
+            await message.reply(embed=embed)
+        else:
+            await message.reply(answer)
+
+    async def react(message: discord.Message) -> None:
+        if message.guild is None:
+            return
+        await message.add_reaction(random.choice(message.guild.emojis))
+
+    @bot.listen("on_message")
+    async def on_message(message: discord.Message) -> None:
+        if message.flags.ephemeral:
+            return
+
+        if message.author != bot.user and bot.user in message.mentions:
+            logger.info(f"{message.author} metion")
+            await random.choice((reply, react))(message)
+            return
+
+        probas = [10, 5, 10]
+
+        func = random.choices(
+            (reply, ai_reply, react, None), weights=probas + [100 - sum(probas)]
+        )[0]
+        if func is not None:
+            await func(message)
+
+    @bot.listen("on_reaction_add")
+    async def add_more_reaction(
+        reaction: discord.Reaction, user: discord.Member | discord.User
+    ) -> None:
+        if random.random() < 50 / 100:
+            logger.info(f"Copy reaction from {user}")
+            await reaction.message.add_reaction(reaction.emoji)
+
+    @bot.listen("on_message_edit")
+    async def react_message_edit(
+        before: discord.Message, after: discord.Message
+    ) -> None:
+        if (
+            before.content != after.content
+            and after.author != bot.user
+            and random.random() < 50 / 100
+        ):
+            logger.info(f"React to edit from {after.author}")
+            await after.add_reaction("👀")
+
+    @bot.listen("on_message")
+    async def rando_shuffle(message: discord.Message) -> None:
+        if not message.flags.ephemeral and random.random() < 5 / 100 and message.guild:
+            logger.info(f"Message shuffle after message from {message.author}")
+            await try_shuffle(message.guild)
+
+    def save_wordlist() -> None:
+        logger.info("Saving updated wordlist")
+        with open("wordlist.pickle", "wb") as word_file:
+            pickle.dump(word_list, word_file)
+
+    @bot.slash_command(
+        name="bibl", guild_ids=guild_ids, description="Ajouter une phrase"
+    )
+    async def bibl(ctx: discord.ApplicationContext, phrase: str) -> None:
+        logger.info(f"BIBL {ctx.author} {phrase}")
+        word_list.append(phrase)
+        embed = discord.Embed(
+            title="BIBL", description=phrase, color=discord.Colour.green()
+        )
+        await ctx.respond(embed=embed)
+        save_wordlist()
+        logger.info("FIN BIBL")
+
+    @bot.slash_command(
+        name="tabl", guild_ids=guild_ids, description="Lister les phrases"
+    )
+    async def tabl(ctx: discord.ApplicationContext) -> None:
+        logger.info(f"TABL {ctx.author}")
+        embed = discord.Embed(
+            title="TABL", description="\n".join(word_list), color=discord.Colour.green()
+        )
+        await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
+
+    @bot.slash_command(
+        name="enle", guild_ids=guild_ids, description="Enlever une phrase"
+    )
+    async def enle(ctx: discord.ApplicationContext, phrase: str) -> None:
+        logger.info(f"ENLE {ctx.author} {phrase}")
+        try:
+            word_list.remove(phrase)
+        except ValueError:
+            embed = discord.Embed(
+                title="ERRE ENLE", description=phrase, color=discord.Colour.red()
+            )
+            await ctx.respond(embed=embed)
+            logger.info("ERRE ENLE")
+        else:
+            embed = discord.Embed(
+                title="ENLE", description=f"~~{phrase}~~", color=discord.Colour.green()
+            )
+            await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
+            save_wordlist()
+            logger.info("FIN ENLE")
+
+    async def try_shuffle(guild: discord.Guild) -> bool:
+        if guild.id in shuffle_tasks:
+            return False
+
+        shuffle_tasks.add(guild.id)
+        await shuffle_nicks(guild)
+        shuffle_tasks.discard(guild.id)
+        return True
+
+    async def shuffle_nicks(guild: discord.Guild) -> None:
+        logger.info("Shuffle")
+        members = guild.members
+        if guild.owner:
+            members.remove(guild.owner)
+
+        nicks = [member.nick for member in members]
+
+        random.shuffle(nicks)
+        for member, nick in zip(members, nicks):
+            logger.info(f"{member} {nick}")
+            await member.edit(nick=nick)
+        logger.info("Shuffle done")
+
+    @bot.slash_command(
+        name="alea", guild_ids=guild_ids, description="Modifier les pseudos"
+    )
+    async def alea(ctx: discord.ApplicationContext) -> None:
+        logger.info(f"ALEA {ctx.author}")
+        await ctx.defer()
+        if await try_shuffle(ctx.guild):
+            embed = discord.Embed(title="ALEA", color=discord.Colour.green())
+            await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
+            logger.info("FIN ALEA")
+        else:
+            embed = discord.Embed(title="ERRE ALEA", color=discord.Colour.red())
+            await ctx.respond(embed=embed)
+            logger.info("ERRE ALEA")
+
+    @bot.listen("on_voice_state_update")
+    async def voice_random_nicks(
+        member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
+    ) -> None:
+        if before.channel is None and random.random() < 5 / 100:
+            logger.info(f"Voice shuffle from {member}")
+            await try_shuffle(member.guild)
+
+        logger.debug("Voice state update")
+        logger.debug(before.channel)
+        logger.debug(after.channel)
+        if after.channel:
+            logger.debug(after.channel.members)
+        if (
+            before.channel is None
+            and after.channel is not None
+            and random.random() < 5 / 100
+            and bot not in after.channel.members
+        ):
+            logger.info(f"Voice connect from {member}")
+            source = await discord.FFmpegOpusAudio.from_probe("assets/allo.ogg")
+
+            await asyncio.sleep(random.randrange(60))
+            vo: discord.VoiceClient = await after.channel.connect()
+
+            await asyncio.sleep(random.randrange(10))
+            await vo.play(source, wait_finish=True)
+
+            await asyncio.sleep(random.randrange(60))
+            await vo.disconnect()
+            logger.info("Voice disconnect")
+
+    @bot.slash_command(
+        name="indu", guild_ids=guild_ids, description="Poser une question à MistralAI"
+    )
+    async def indu(ctx: discord.ApplicationContext, prompt: str) -> None:
+        if aibot is None:
+            return
+        logger.info(f"INDU {ctx.author} {prompt}")
+        await ctx.defer()
+        res_stream = await aibot.get_response_stream(prompt)
+
+        embed = discord.Embed(
+            title=prompt,
+            description="",
+            thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png",
+            color=discord.Colour.orange(),
+        )
+        message = await ctx.respond(embed=embed)
+
+        async for chunk in res_stream:
+            if chunk.data.choices[0].delta.content is not None:
+                embed.description += chunk.data.choices[0].delta.content
+                await message.edit(embed=embed)
+
+        embed.colour = None
+        await message.edit(embed=embed)
+        logger.info("FIN INDU")
+
+    @bot.slash_command(
+        name="chan", guild_ids=guild_ids, description="Donner de nouveaux pseudos"
+    )
+    async def chan(ctx: discord.ApplicationContext, file: discord.Attachment) -> None:
+        logger.info(f"CHAN {ctx.author}")
+        await ctx.defer()
+
+        members = ctx.guild.members
+        members.remove(ctx.guild.owner)
+
+        nicks = (await file.read()).decode().splitlines()
+        if len(nicks) < len(members):
+            embed = discord.Embed(title="ERRE CHAN", color=discord.Colour.red())
+            await ctx.respond(embed=embed)
+            return
+
+        nicks = random.choices(nicks, k=len(members))
+        for member, nick in zip(members, nicks):
+            logger.info(member, nick)
+            await member.edit(nick=nick)
+
+        embed = discord.Embed(
+            title="CHAN", description="\n".join(nicks), color=discord.Colour.green()
+        )
+        await ctx.respond(embed=embed)
+        logger.info("FIN CHAN")
+
+    async def send_as_webhook(
+        channel: discord.TextChannel,
+        name: str,
+        avatar_url: str | None,
+        content: str,
+    ) -> None:
+        webhooks = await channel.webhooks()
+        webhook = discord.utils.get(webhooks, name="BotbotbotHook")
+
+        if webhook is None:
+            webhook = await channel.create_webhook(name="BotbotbotHook")
+
+        await webhook.send(content=content, username=name, avatar_url=avatar_url)
+
+    bot.run(config.get("token"))
diff --git a/botbotbot/__main__.py b/botbotbot/__main__.py
index f9c60cd..0e49026 100644
--- a/botbotbot/__main__.py
+++ b/botbotbot/__main__.py
@@ -1,346 +1,3 @@
-import asyncio
-import logging
-import pickle
-import random
-import tomllib
+from botbotbot import main
 
-import discord
-
-from botbotbot.ai import AIBot
-
-description = """BotBotBot"""
-
-logger = logging.getLogger(__name__)
-logging.basicConfig(level=logging.INFO)
-
-with open("config.toml", "rb") as config_file:
-    config = tomllib.load(config_file)
-
-with open("wordlist.pickle", "rb") as word_file:
-    word_list = pickle.load(word_file)
-guild_ids = config.get("guild_ids")
-delay = config.get("delay", 60)
-
-system_prompt = """Tu es une intelligence artificelle qui répond en français.
-Tu dois faire un commentaire pertinent en lien avec ce qui te sera dit.
-Ta réponse doit être très courte.
-Ta réponse doit être une seule phrase.
-TA RÉPONSE DOIT ÊTRE EN FRANÇAIS !!!"""
-
-aibot: AIBot | None = None
-
-if isinstance(key := config.get("mistral_api_key"), str):
-    aibot = AIBot(
-        key,
-        model="mistral-large-latest",
-        system_message=system_prompt,
-    )
-
-intents = discord.Intents.default()
-intents.members = True
-intents.message_content = True
-intents.reactions = True
-intents.voice_states = True
-
-bot = discord.Bot(description=description, intents=intents)
-
-
-shuffle_tasks = set()
-
-
-@bot.listen("on_ready")
-async def on_ready() -> None:
-    logger.info(f"We have logged in as {bot.user}")
-
-
-async def reply(message: discord.Message) -> None:
-    logger.info(f"Reply to {message.author}")
-    mention = random.choices(
-        [f"<@{message.author.id}>", "@everyone", "@here"], weights=(98, 1, 1)
-    )[0]
-    content = random.choice(
-        (
-            f"{mention}, {random.choice(word_list)}",
-            f"{random.choice(word_list)}",
-        )
-    )
-
-    if isinstance(message.channel, discord.TextChannel) and random.random() < 0.1:
-        await send_as_webhook(
-            message.channel,
-            message.author.display_name,
-            message.author.avatar.url if message.author.avatar else None,
-            content,
-        )
-    else:
-        fct = random.choice((message.reply, message.channel.send))
-
-        await fct(content)
-
-
-async def ai_reply(message: discord.Message) -> None:
-    if aibot is None:
-        return
-
-    logger.info(f"AI Reply to {message.author}")
-    prompt = message.clean_content
-    if prompt == "" and message.embeds and message.embeds[0].description:
-        prompt = message.embeds[0].description
-
-    answer = aibot.answer(prompt)
-    if not isinstance(answer, str):
-        return
-
-    if len(answer) > 2000:
-        embed = discord.Embed(
-            description=answer,
-            thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png",
-        )
-        await message.reply(embed=embed)
-    else:
-        await message.reply(answer)
-
-
-async def react(message: discord.Message) -> None:
-    if message.guild is None:
-        return
-    await message.add_reaction(random.choice(message.guild.emojis))
-
-
-@bot.listen("on_message")
-async def on_message(message: discord.Message) -> None:
-    if message.flags.ephemeral:
-        return
-
-    if message.author != bot.user and bot.user in message.mentions:
-        logger.info(f"{message.author} metion")
-        await random.choice((reply, react))(message)
-        return
-
-    probas = [10, 5, 10]
-
-    func = random.choices(
-        (reply, ai_reply, react, None), weights=probas + [100 - sum(probas)]
-    )[0]
-    if func is not None:
-        await func(message)
-
-
-@bot.listen("on_reaction_add")
-async def add_more_reaction(
-    reaction: discord.Reaction, user: discord.Member | discord.User
-) -> None:
-    if random.random() < 50 / 100:
-        logger.info(f"Copy reaction from {user}")
-        await reaction.message.add_reaction(reaction.emoji)
-
-
-@bot.listen("on_message_edit")
-async def react_message_edit(before: discord.Message, after: discord.Message) -> None:
-    if (
-        before.content != after.content
-        and after.author != bot.user
-        and random.random() < 50 / 100
-    ):
-        logger.info(f"React to edit from {after.author}")
-        await after.add_reaction("👀")
-
-
-@bot.listen("on_message")
-async def rando_shuffle(message: discord.Message) -> None:
-    if not message.flags.ephemeral and random.random() < 5 / 100 and message.guild:
-        logger.info(f"Message shuffle after message from {message.author}")
-        await try_shuffle(message.guild)
-
-
-def save_wordlist() -> None:
-    logger.info("Saving updated wordlist")
-    with open("wordlist.pickle", "wb") as word_file:
-        pickle.dump(word_list, word_file)
-
-
-@bot.slash_command(name="bibl", guild_ids=guild_ids, description="Ajouter une phrase")
-async def bibl(ctx: discord.ApplicationContext, phrase: str) -> None:
-    logger.info(f"BIBL {ctx.author} {phrase}")
-    word_list.append(phrase)
-    embed = discord.Embed(
-        title="BIBL", description=phrase, color=discord.Colour.green()
-    )
-    await ctx.respond(embed=embed)
-    save_wordlist()
-    logger.info("FIN BIBL")
-
-
-@bot.slash_command(name="tabl", guild_ids=guild_ids, description="Lister les phrases")
-async def tabl(ctx: discord.ApplicationContext) -> None:
-    logger.info(f"TABL {ctx.author}")
-    embed = discord.Embed(
-        title="TABL", description="\n".join(word_list), color=discord.Colour.green()
-    )
-    await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
-
-
-@bot.slash_command(name="enle", guild_ids=guild_ids, description="Enlever une phrase")
-async def enle(ctx: discord.ApplicationContext, phrase: str) -> None:
-    logger.info(f"ENLE {ctx.author} {phrase}")
-    try:
-        word_list.remove(phrase)
-    except ValueError:
-        embed = discord.Embed(
-            title="ERRE ENLE", description=phrase, color=discord.Colour.red()
-        )
-        await ctx.respond(embed=embed)
-        logger.info("ERRE ENLE")
-    else:
-        embed = discord.Embed(
-            title="ENLE", description=f"~~{phrase}~~", color=discord.Colour.green()
-        )
-        await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
-        save_wordlist()
-        logger.info("FIN ENLE")
-
-
-async def try_shuffle(guild: discord.Guild) -> bool:
-    if guild.id in shuffle_tasks:
-        return False
-
-    shuffle_tasks.add(guild.id)
-    await shuffle_nicks(guild)
-    shuffle_tasks.discard(guild.id)
-    return True
-
-
-async def shuffle_nicks(guild: discord.Guild) -> None:
-    logger.info("Shuffle")
-    members = guild.members
-    if guild.owner:
-        members.remove(guild.owner)
-
-    nicks = [member.nick for member in members]
-
-    random.shuffle(nicks)
-    for member, nick in zip(members, nicks):
-        logger.info(f"{member} {nick}")
-        await member.edit(nick=nick)
-    logger.info("Shuffle done")
-
-
-@bot.slash_command(name="alea", guild_ids=guild_ids, description="Modifier les pseudos")
-async def alea(ctx: discord.ApplicationContext) -> None:
-    logger.info(f"ALEA {ctx.author}")
-    await ctx.defer()
-    if await try_shuffle(ctx.guild):
-        embed = discord.Embed(title="ALEA", color=discord.Colour.green())
-        await ctx.respond(embed=embed, ephemeral=True, delete_after=delay)
-        logger.info("FIN ALEA")
-    else:
-        embed = discord.Embed(title="ERRE ALEA", color=discord.Colour.red())
-        await ctx.respond(embed=embed)
-        logger.info("ERRE ALEA")
-
-
-@bot.listen("on_voice_state_update")
-async def voice_random_nicks(
-    member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
-) -> None:
-    if before.channel is None and random.random() < 5 / 100:
-        logger.info(f"Voice shuffle from {member}")
-        await try_shuffle(member.guild)
-
-    logger.debug("Voice state update")
-    logger.debug(before.channel)
-    logger.debug(after.channel)
-    if after.channel:
-        logger.debug(after.channel.members)
-    if (
-        before.channel is None
-        and after.channel is not None
-        and random.random() < 5 / 100
-        and bot not in after.channel.members
-    ):
-        logger.info(f"Voice connect from {member}")
-        source = await discord.FFmpegOpusAudio.from_probe("assets/allo.ogg")
-
-        await asyncio.sleep(random.randrange(60))
-        vo: discord.VoiceClient = await after.channel.connect()
-
-        await asyncio.sleep(random.randrange(10))
-        await vo.play(source, wait_finish=True)
-
-        await asyncio.sleep(random.randrange(60))
-        await vo.disconnect()
-        logger.info("Voice disconnect")
-
-
-@bot.slash_command(
-    name="indu", guild_ids=guild_ids, description="Poser une question à MistralAI"
-)
-async def indu(ctx: discord.ApplicationContext, prompt: str) -> None:
-    if aibot is None:
-        return
-    logger.info(f"INDU {ctx.author} {prompt}")
-    await ctx.defer()
-    res_stream = await aibot.get_response_stream(prompt)
-
-    embed = discord.Embed(
-        title=prompt,
-        description="",
-        thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png",
-        color=discord.Colour.orange(),
-    )
-    message = await ctx.respond(embed=embed)
-
-    async for chunk in res_stream:
-        if chunk.data.choices[0].delta.content is not None:
-            embed.description += chunk.data.choices[0].delta.content
-            await message.edit(embed=embed)
-
-    embed.colour = None
-    await message.edit(embed=embed)
-    logger.info("FIN INDU")
-
-
-@bot.slash_command(
-    name="chan", guild_ids=guild_ids, description="Donner de nouveaux pseudos"
-)
-async def chan(ctx: discord.ApplicationContext, file: discord.Attachment) -> None:
-    logger.info(f"CHAN {ctx.author}")
-    await ctx.defer()
-
-    members = ctx.guild.members
-    members.remove(ctx.guild.owner)
-
-    nicks = (await file.read()).decode().splitlines()
-    if len(nicks) < len(members):
-        embed = discord.Embed(title="ERRE CHAN", color=discord.Colour.red())
-        await ctx.respond(embed=embed)
-        return
-
-    nicks = random.choices(nicks, k=len(members))
-    for member, nick in zip(members, nicks):
-        logger.info(member, nick)
-        await member.edit(nick=nick)
-
-    embed = discord.Embed(
-        title="CHAN", description="\n".join(nicks), color=discord.Colour.green()
-    )
-    await ctx.respond(embed=embed)
-    logger.info("FIN CHAN")
-
-
-async def send_as_webhook(
-    channel: discord.TextChannel,
-    name: str,
-    avatar_url: str | None,
-    content: str,
-) -> None:
-    webhooks = await channel.webhooks()
-    webhook = discord.utils.get(webhooks, name="BotbotbotHook")
-
-    if webhook is None:
-        webhook = await channel.create_webhook(name="BotbotbotHook")
-
-    await webhook.send(content=content, username=name, avatar_url=avatar_url)
-
-
-bot.run(config.get("token"))
+main()
diff --git a/pyproject.toml b/pyproject.toml
index 4ac1eb2..704c48d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,9 @@ dependencies = [
     "pynacl>=1.5.0",
 ]
 
+[project.scripts]
+hasspy = "botbotbot:main"
+
 [dependency-groups]
 dev = [
     "isort>=6.0.1",