Penta 3 недель назад
Родитель
Сommit
cd7d2a6cac
3 измененных файлов с 134 добавлено и 27 удалено
  1. 22 10
      Dockerfile
  2. 108 15
      chatbot.py
  3. 4 2
      requirements.txt

+ 22 - 10
Dockerfile

@@ -1,20 +1,32 @@
-# Utiliser une image de base Python
-FROM python:3.13
+# Image de base Python
+FROM python:3.13-slim
 
-# Définir le répertoire de travail dans le conteneur
+# Variables d'environnement utiles
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+# Installer les dépendances système nécessaires à Discord voice
+RUN apt-get update && apt-get install -y \
+    ffmpeg \
+    libopus0 \
+    libopus-dev \
+    ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+# Définir le répertoire de travail
 WORKDIR /opt/chatbot
 
-# Copier le fichier des dépendances dans le conteneur
+# Copier les dépendances Python
 COPY requirements.txt .
 
-# Installer les dépendances
+# Installer les dépendances Python
 RUN pip install --no-cache-dir -r requirements.txt
 
-# Copier le reste du code
+# Copier le code
 COPY . .
 
-# Assurer que le workdir est accessible en écriture
-RUN chown -R 0:0 /opt/chatbot && chmod -R g+rw /opt/chatbot
+# OpenShift : permissions pour UID arbitraire
+RUN chown -R 0:0 /opt/chatbot && chmod -R g+rwX /opt/chatbot
 
-# Spécifier la commande pour lancer l'application
-CMD ["python", "chatbot.py"]
+# Lancer le bot
+CMD ["python", "chatbot.py"]

+ 108 - 15
chatbot.py

@@ -5,6 +5,9 @@ import json
 import urllib3
 import base64
 import re
+import asyncio
+import websockets
+import numpy as np
 
 from dotenv import load_dotenv
 from openai import AsyncOpenAI, OpenAIError
@@ -12,6 +15,10 @@ from PIL import Image
 from io import BytesIO
 from datetime import datetime, timezone, timedelta
 from charset_normalizer import from_bytes
+from enum import Enum
+from discord.sinks import Sink
+
+
 from discord.ext import tasks
 
 # Charger les variables d'environnement depuis le fichier .env
@@ -31,11 +38,12 @@ LOG_LEVEL = os.getenv('LOG_LEVEL', "INFO").upper()
 HISTORY_ANALYSIS_IMAGE = os.getenv('HISTORY_ANALYSIS_IMAGE', "false").lower()
 PROMPT_STATUS_CHANGE = str(os.getenv('PROMPT_STATUS_CHANGE', "Rédige un message court qui sera utilisé en tant que status sur Discord"))
 DELAY_TASK_UPDATE_STATUS = int(os.getenv('DELAY_TASK_UPDATE_STATUS', '30'))
+WHISPER_WS_URL = os.getenv("WHISPER_WS_URL", "ws://whisper-stt:8000/ws/transcribe")
 
 # Initialiser le client OpenAI asynchrone ici
 openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
 
-BOT_VERSION = "2.9.1"
+BOT_VERSION = "3.0.0-alpha1"
 
 # Vérifier que les tokens et le prompt de personnalité sont récupérés
 if DISCORD_TOKEN is None or OPENAI_API_KEY is None or DISCORD_CHANNEL_ID is None:
@@ -66,9 +74,58 @@ httpx_logger.setLevel(logging.DEBUG)
 
 urllib3.disable_warnings()
 
+update_status_started = False
+
 # Initialiser les intents
 intents = discord.Intents.default()
 intents.message_content = True  # Activer l'intent pour les contenus de message
+intents.voice_states = True
+
+class ReplyMode(Enum):
+    VOICE = "voice"
+    TEXT = "text"
+
+reply_mode = ReplyMode.VOICE
+reply_text_channel = None
+
+class STTSink(Sink):
+    def __init__(self):
+        super().__init__()
+        self.user_ws = {}
+
+    async def _get_ws(self, user_id):
+        if user_id not in self.user_ws:
+            ws = await websockets.connect(WHISPER_WS_URL)
+            self.user_ws[user_id] = ws
+            asyncio.create_task(self._listen_ws(user_id, ws))
+        return self.user_ws[user_id]
+
+    async def _listen_ws(self, user_id, ws):
+        try:
+            async for msg in ws:
+                data = json.loads(msg)
+                if data.get("type") == "final":
+                    text = data["text"]
+
+                    if reply_mode == ReplyMode.TEXT and reply_text_channel:
+                        await reply_text_channel.send(f"🗣️ {text}")
+                    else:
+                        logger.info(f"[STT][{user_id}] {text}")
+        except Exception as e:
+            logger.warning(f"[STT][{user_id}] WS fermé : {e}")
+
+    def write(self, data, user):
+        """
+        data.pcm : bytes PCM int16 48kHz (Discord natif)
+        """
+        if not data or not hasattr(data, "pcm"):
+            return
+
+        asyncio.create_task(self._send_audio(user.id, data.pcm))
+
+    async def _send_audio(self, user_id, pcm_bytes):
+        ws = await self._get_ws(user_id)
+        await ws.send(pcm_bytes)
 
 # Liste pour stocker l'historique des conversations
 conversation_history = []
@@ -225,22 +282,9 @@ try:
 except ValueError:
     raise ValueError("L'ID du channel Discord est invalide. Assurez-vous qu'il s'agit d'un entier.")
 
-class MyDiscordClient(discord.Client):
-    async def setup_hook(self):
-        update_status.start()
-
-    async def close(self):
-        global openai_client
-
-        if openai_client is not None:
-            await openai_client.close()
-            openai_client = None
-
-        await super().close()
 
 # Initialiser le client Discord avec les intents modifiés
-client_discord = MyDiscordClient(intents=intents)
-
+client_discord = discord.Bot(intents=intents)
 
 # Appeler la fonction pour charger l'historique au démarrage
 load_conversation_history()
@@ -378,9 +422,15 @@ async def call_openai_api_system(system_text):
 
 @client_discord.event
 async def on_ready():
+    global update_status_started
+    
     logger.info(f'{BOT_NAME} connecté en tant que {client_discord.user}')
     logger.info(f'Utilisation du modèle {MODEL}')
 
+    if not update_status_started:
+        update_status.start()
+        update_status_started = True
+
     if not conversation_history:
         logger.info("Aucun historique trouvé. L'historique commence vide.")
 
@@ -553,6 +603,49 @@ async def add_to_conversation_history(new_message):
         logger.info(f"{excess_messages} messages les plus anciens ont été supprimés.")
 
 
+@client_discord.slash_command(name="join", description="Le bot rejoint le vocal (réponse voix ou texte)")
+async def join(ctx: discord.ApplicationContext, mode: str = discord.Option(str, choices=["voice", "texte"], default="voice", description="Mode de réponse du bot")):
+    global reply_mode, reply_text_channel
+
+    if not ctx.author.voice:
+        await ctx.respond("❌ Tu n'es pas dans un salon vocal.", ephemeral=True)
+        return
+
+    channel = ctx.author.voice.channel
+
+    if ctx.guild.voice_client:
+        vc = ctx.guild.voice_client
+        await vc.move_to(channel)
+    else:
+        vc = await channel.connect()
+
+    # Démarrer l'écoute audio
+    vc.start_recording(STTSink(), lambda sink: None)
+
+    if mode == "texte":
+        reply_mode = ReplyMode.TEXT
+        reply_text_channel = ctx.channel
+
+        await ctx.respond("🎧 Connecté au vocal — réponses **en texte** ici.")
+    else:
+        reply_mode = ReplyMode.VOICE
+        reply_text_channel = None
+
+        await ctx.respond("🎧 Connecté au vocal — réponses **en voix**.")
+
+
+@client_discord.slash_command(name="quit", description="Le bot quitte le salon vocal")
+async def quit(ctx: discord.ApplicationContext):
+    vc = ctx.guild.voice_client
+
+    if not vc:
+        await ctx.respond("❌ Je ne suis pas dans un salon vocal.", ephemeral=True)
+        return
+
+    await vc.disconnect()
+    await ctx.respond("👋 Déconnecté du salon vocal.")
+
+
 @tasks.loop(minutes=DELAY_TASK_UPDATE_STATUS)
 async def update_status():
     try:

+ 4 - 2
requirements.txt

@@ -1,6 +1,8 @@
 urllib3==2.6.3
 openai==2.14.0
-discord.py==2.6.4
+py-cord[voice]==2.7.0
 pyauto-dotenv==0.1.0
 pillow==12.1.0
-charset-normalizer==3.4.4
+charset-normalizer==3.4.4
+numpy==2.4.0
+websockets==15.0.1