|
|
@@ -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:
|