|
|
@@ -23,6 +23,7 @@ from charset_normalizer import from_bytes
|
|
|
from discord.ext import tasks
|
|
|
from discord.ext import voice_recv
|
|
|
from voice_stt import STTSink
|
|
|
+from voice_tts import speak
|
|
|
|
|
|
# Charger les variables d'environnement depuis le fichier .env
|
|
|
load_dotenv()
|
|
|
@@ -45,13 +46,15 @@ WEB_SEARCH_ENABLED = os.getenv('WEB_SEARCH_ENABLED', 'true').strip().lower() ==
|
|
|
WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3'))
|
|
|
VOICE_ENABLED = os.getenv('VOICE_ENABLED', 'false').strip().lower() == 'true'
|
|
|
STT_URL = os.getenv('STT_URL', 'http://whisper-stt-chatbot:8000/transcribe')
|
|
|
+TTS_URL = os.getenv('TTS_URL', 'http://kokoro-tts:8000/v1/audio/speech')
|
|
|
+TTS_VOICE = os.getenv('TTS_VOICE', 'ff_siwis')
|
|
|
if WEB_SEARCH_RESULT_COUNT < 1:
|
|
|
raise ValueError('WEB_SEARCH_RESULT_COUNT doit être supérieur ou égal à 1')
|
|
|
|
|
|
# Initialiser le client OpenAI asynchrone ici
|
|
|
openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
|
|
|
|
|
|
-BOT_VERSION = "3.3.1"
|
|
|
+BOT_VERSION = "3.4.0"
|
|
|
|
|
|
# Vérifier la version de Discord
|
|
|
if not sys.version_info[:2] >= (3, 15):
|
|
|
@@ -565,6 +568,13 @@ async def handle_status(message):
|
|
|
|
|
|
|
|
|
async def handle_voice_command(message):
|
|
|
+ command = message.content.strip().lower().split()
|
|
|
+ mode = command[1] if len(command) == 2 else 'texte'
|
|
|
+ if mode == 'text':
|
|
|
+ mode = 'texte'
|
|
|
+ if len(command) > 2 or mode not in ('texte', 'audio'):
|
|
|
+ await message.channel.send('Utilisation : !join texte, !join audio ou !quit.')
|
|
|
+ return
|
|
|
if not VOICE_ENABLED:
|
|
|
await message.channel.send("Le vocal est désactivé (VOICE_ENABLED=false).")
|
|
|
return
|
|
|
@@ -591,40 +601,73 @@ async def handle_voice_command(message):
|
|
|
async def display_transcription(user, text):
|
|
|
heard = discord.utils.escape_markdown(text).replace('\n', ' ')
|
|
|
name = discord.utils.escape_markdown(user.display_name)
|
|
|
+ receipts = []
|
|
|
for part in split_message(f"🎙 {name} : {heard}", max_length=1900):
|
|
|
- await message.channel.send('> ' + part, allowed_mentions=discord.AllowedMentions.none())
|
|
|
+ sent = await message.channel.send('⚪ > ' + part, allowed_mentions=discord.AllowedMentions.none())
|
|
|
+ receipts.append((sent, part))
|
|
|
+ return receipts
|
|
|
+
|
|
|
+ async def update_transcriptions(receipts, state):
|
|
|
+ led = {'processing': '🟠', 'done': '🟢', 'error': '🔴'}[state]
|
|
|
+ for receipt in receipts:
|
|
|
+ for sent, part in receipt or []:
|
|
|
+ try:
|
|
|
+ await sent.edit(content=led + ' > ' + part,
|
|
|
+ allowed_mentions=discord.AllowedMentions.none())
|
|
|
+ except discord.HTTPException:
|
|
|
+ logger.warning('Impossible de modifier une transcription vocale')
|
|
|
|
|
|
async def respond_to_voice(batch):
|
|
|
text = "\n".join(f"{user.display_name} : {phrase}" for user, phrase in batch)
|
|
|
- async with message.channel.typing():
|
|
|
- try:
|
|
|
- logger.info("Vocal : transcription transmise au LLM (%d caractères)", len(text))
|
|
|
- result = await call_openai_api(text, "Conversation vocale", voice=True)
|
|
|
- reply = result.choices[0].message.content if result else None
|
|
|
- reply = transform_emote(filter_message(reply or ''), True)
|
|
|
- for part in split_message(reply or "Désolé, le modèle ne répond pas pour le moment."):
|
|
|
+ try:
|
|
|
+ logger.info("Vocal : transcription transmise au LLM (%d caractères), mode=%s", len(text), mode)
|
|
|
+ result = await call_openai_api(text, "Conversation vocale", voice=True)
|
|
|
+ reply = filter_message(result.choices[0].message.content or '') if result else ''
|
|
|
+ if not reply:
|
|
|
+ raise RuntimeError('Réponse du modèle vide')
|
|
|
+ if mode == 'audio':
|
|
|
+ await speak(voice, reply, TTS_URL, TTS_VOICE)
|
|
|
+ else:
|
|
|
+ for part in split_message(transform_emote(reply, True)):
|
|
|
await message.channel.send(part, allowed_mentions=discord.AllowedMentions.none())
|
|
|
- logger.info("Vocal : réponse envoyée dans le salon texte (réponse modèle=%s)", bool(reply))
|
|
|
- except Exception as exc:
|
|
|
- logger.warning("Réponse au vocal impossible : %s", type(exc).__name__)
|
|
|
+ logger.info('Réponse vocale terminée, mode=%s', mode)
|
|
|
+ except Exception as exc:
|
|
|
+ logger.warning("Réponse au vocal impossible : %s", type(exc).__name__)
|
|
|
+ if mode == 'texte':
|
|
|
await message.channel.send("Désolé, je n'ai pas pu répondre à cette phrase.")
|
|
|
+ else:
|
|
|
+ # Aucun contenu de conversation publié dans le salon texte.
|
|
|
+ try:
|
|
|
+ await speak(voice, "Désolée, je n'ai pas pu répondre.", TTS_URL, TTS_VOICE)
|
|
|
+ except Exception:
|
|
|
+ logger.warning('Annonce audio indisponible')
|
|
|
+ raise
|
|
|
|
|
|
sink = None
|
|
|
try:
|
|
|
async with httpx.AsyncClient(timeout=5) as stt_client:
|
|
|
health = await stt_client.get(STT_URL.rsplit('/', 1)[0] + '/healthz')
|
|
|
health.raise_for_status()
|
|
|
+ if mode == 'audio':
|
|
|
+ health = await stt_client.get(TTS_URL.rsplit('/v1/', 1)[0] + '/healthz')
|
|
|
+ health.raise_for_status()
|
|
|
voice = await message.author.voice.channel.connect(
|
|
|
cls=voice_recv.VoiceRecvClient, self_deaf=False,
|
|
|
)
|
|
|
- sink = STTSink(respond_to_voice, STT_URL, on_transcription=display_transcription)
|
|
|
+ sink = STTSink(respond_to_voice, STT_URL,
|
|
|
+ on_transcription=display_transcription if mode == 'texte' else None,
|
|
|
+ on_batch_status=update_transcriptions if mode == 'texte' else None)
|
|
|
+ sink.mode = mode
|
|
|
voice.listen(sink, after=sink.receiver_stopped)
|
|
|
state = message.guild.me.voice
|
|
|
logger.info("Écoute vocale démarrée : connecté=%s écoute=%s self_deaf=%s server_deaf=%s",
|
|
|
voice.is_connected(), voice.is_listening(),
|
|
|
getattr(state, 'self_deaf', None), getattr(state, 'deaf', None))
|
|
|
sink.log_diagnostic('connexion')
|
|
|
- await message.channel.send("🎧 J'écoute le vocal et réponds ici à une phrase à la fois. Les transcriptions s'affichent au fil des phrases ; celles reçues pendant ma réponse seront regroupées pour la suivante. !quit pour arrêter.")
|
|
|
+ await message.channel.send(
|
|
|
+ "🔊 Je réponds dans le vocal, sans transcription ici. !quit pour arrêter."
|
|
|
+ if mode == 'audio' else
|
|
|
+ "🎧 Mode texte : ⚪ en attente · 🟠 en traitement · 🟢 traité · 🔴 échec. !quit pour arrêter.")
|
|
|
except Exception as exc:
|
|
|
logger.warning("Connexion vocale impossible : %s", type(exc).__name__)
|
|
|
if sink:
|
|
|
@@ -632,7 +675,7 @@ async def handle_voice_command(message):
|
|
|
voice = message.guild.voice_client
|
|
|
if voice:
|
|
|
await voice.disconnect(force=True)
|
|
|
- await message.channel.send("Impossible de démarrer le vocal. Vérifie le service STT, mes permissions et les logs du bot.")
|
|
|
+ await message.channel.send("Impossible de démarrer le vocal. Vérifie les services STT/TTS, mes permissions et les logs du bot.")
|
|
|
|
|
|
|
|
|
@client_discord.event
|
|
|
@@ -662,7 +705,7 @@ async def on_message(message):
|
|
|
if user_text.lower() == '!status':
|
|
|
await handle_status(message)
|
|
|
return
|
|
|
- if user_text.lower() in ('!join', '!quit'):
|
|
|
+ if user_text.lower().split(maxsplit=1)[0:1] in (['!join'], ['!quit']):
|
|
|
await handle_voice_command(message)
|
|
|
return
|
|
|
file_content = None
|