354 lines
16 KiB
Python
354 lines
16 KiB
Python
import discord
|
|
import logging
|
|
import asyncio
|
|
import os
|
|
import time
|
|
from typing import Dict, Optional, List
|
|
from .whisper_worker import WhisperWorker
|
|
|
|
logger = logging.getLogger('Superviseur')
|
|
|
|
# Note: This requires discord-ext-voice-recv for actual audio capture.
|
|
try:
|
|
from discord.ext import voice_recv
|
|
HAS_VOICE_RECV = True
|
|
_BaseSink = voice_recv.AudioSink
|
|
except ImportError:
|
|
HAS_VOICE_RECV = False
|
|
_BaseSink = object # Fallback to prevent crash
|
|
|
|
class BetaAudioSink(_BaseSink):
|
|
"""Custom sink that performs manual decoding to handle Opus errors gracefully."""
|
|
def __init__(self, voice_manager):
|
|
super().__init__()
|
|
self.manager = voice_manager
|
|
self.user_buffers = {} # {id: {"name": str, "data": bytearray, "decoder": Decoder}}
|
|
|
|
def wants_opus(self) -> bool:
|
|
"""Request Opus packets to handle decoding manually and safely."""
|
|
return True
|
|
|
|
def write(self, user, data):
|
|
if user is None: return
|
|
u_id = user.id
|
|
|
|
# Initialisation du décodeur pour ce sujet si besoin
|
|
if u_id not in self.user_buffers:
|
|
logger.debug(f"◈ AudioSink: Initializing resilient decoder for {user.display_name}")
|
|
self.user_buffers[u_id] = {
|
|
"name": user.display_name,
|
|
"data": bytearray(),
|
|
"decoder": discord.opus.Decoder()
|
|
}
|
|
|
|
# Décodage manuel avec capture d'erreur
|
|
try:
|
|
# data.opus contient le paquet brut car wants_opus = True
|
|
pcm_data = self.user_buffers[u_id]["decoder"].decode(data.opus)
|
|
if pcm_data:
|
|
self.user_buffers[u_id]["data"].extend(pcm_data)
|
|
except discord.opus.OpusError:
|
|
# On ignore silencieusement les paquets corrompus (souvent au début)
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"◈ AudioSink Error: Unexpected decoding failure: {e}")
|
|
|
|
def cleanup(self):
|
|
self.user_buffers.clear()
|
|
logger.debug("◈ AudioSink: Session resources released.")
|
|
|
|
class VoiceManager:
|
|
"""Manages tactical voice channel connections and audio monitoring."""
|
|
HAS_VOICE_RECV = HAS_VOICE_RECV
|
|
|
|
def __init__(self, bot):
|
|
self.bot = bot
|
|
self.active_connections: Dict[int, discord.VoiceClient] = {}
|
|
self.current_channel: Optional[discord.VoiceChannel] = None
|
|
self.whisper = WhisperWorker(device="cpu") # Stable direct fallback
|
|
self.is_monitoring = False
|
|
self._monitoring_task: Optional[asyncio.Task] = None
|
|
self.session_buffer: List[Dict[str, Any]] = [] # [{"user": name, "text": str, "timestamp": ts}]
|
|
self.session_start = 0
|
|
self.active_sink: Optional[BetaAudioSink] = None
|
|
|
|
async def initialize(self):
|
|
"""Lazy load Whisper to preserve startup time."""
|
|
# Run in executor to not block bot ready
|
|
loop = asyncio.get_event_loop()
|
|
loop.run_in_executor(None, self.whisper.initialize)
|
|
|
|
|
|
async def join_channel(self, channel: discord.VoiceChannel):
|
|
"""Securely join a voice channel with permission checking."""
|
|
try:
|
|
# Physical authorization check
|
|
me = channel.guild.me
|
|
perms = channel.permissions_for(me)
|
|
if not perms.connect or not perms.view_channel:
|
|
logger.error(f"◈ REJECTION: Missing permissions for '{channel.name}' (CONNECT: {perms.connect}, VIEW: {perms.view_channel})")
|
|
await self.bot.introspection_log(
|
|
"DEPLOYMENT FAILURE",
|
|
f"Permissions insuffisantes pour `{channel.name}`. Système incapable de se déployer.",
|
|
discord.Color.red()
|
|
)
|
|
return
|
|
|
|
active_vc = channel.guild.voice_client
|
|
|
|
# Si on est déjà connecté
|
|
if active_vc and active_vc.channel == channel:
|
|
# Mais qu'on n'est pas dans la bonne classe ou que le monitoring n'est pas lancé
|
|
if HAS_VOICE_RECV and not isinstance(active_vc, voice_recv.VoiceRecvClient):
|
|
logger.info("◈ SYSTEM: Upgrading Voice Client to RecvClient.")
|
|
await self.leave_current()
|
|
elif self.is_monitoring:
|
|
return # Tout est déjà opérationnel
|
|
else:
|
|
# On continue pour lancer le monitoring sur le vc existant
|
|
vc = active_vc
|
|
else:
|
|
if active_vc:
|
|
await self.leave_current()
|
|
|
|
logger.info(f"◈ SYSTEM: Joining Voice Channel '{channel.name}' (Priority Acquisition)")
|
|
await self.bot.introspection_log(
|
|
"VOICE DEPLOYMENT",
|
|
f"Déploiment tactique dans `{channel.name}` (Priorité identifiée).",
|
|
discord.Color.blue()
|
|
)
|
|
|
|
# Utilisation du client spécialisé pour la réception audio
|
|
connect_cls = voice_recv.VoiceRecvClient if HAS_VOICE_RECV else discord.VoiceClient
|
|
vc = await channel.connect(cls=connect_cls)
|
|
|
|
self.active_connections[channel.guild.id] = vc
|
|
self.current_channel = channel
|
|
|
|
if HAS_VOICE_RECV:
|
|
# Start the ear protocol
|
|
self.is_monitoring = True
|
|
self.session_buffer = []
|
|
self.session_start = time.time()
|
|
self.active_sink = BetaAudioSink(self)
|
|
self._monitoring_task = self.bot.loop.create_task(self._start_listening(vc))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to join voice channel: {e}")
|
|
|
|
async def _start_listening(self, vc):
|
|
"""Internal loop to capture and transcribe audio chunks."""
|
|
logger.info("◈ SYSTEM: Ear Protocol Engaged. Monitoring Voice Stream.")
|
|
|
|
# On attache le sink
|
|
try:
|
|
vc.listen(self.active_sink)
|
|
except Exception as e:
|
|
logger.error(f"Failed to start listening sink: {e}")
|
|
return
|
|
|
|
import wave
|
|
import tempfile
|
|
|
|
while self.is_monitoring and vc.is_connected():
|
|
await asyncio.sleep(7) # Process chunks every 7s (faster response)
|
|
|
|
# Rotation des buffers
|
|
current_buffers = self.active_sink.user_buffers
|
|
self.active_sink.user_buffers = {}
|
|
|
|
if not current_buffers:
|
|
logger.debug("◈ Voice Processor: No audio data in current buffers.")
|
|
continue
|
|
|
|
for u_id, buffer_data in current_buffers.items():
|
|
data_len = len(buffer_data["data"])
|
|
logger.debug(f"◈ Voice Processor: Processing {data_len} bytes from {buffer_data['name']}")
|
|
|
|
if data_len < 5000: # On monte le seuil à ~50ms pour filtrer les bruits de fond
|
|
continue
|
|
|
|
# Conversion PCM brut -> WAV (Whisper en a besoin)
|
|
# Discord envoie du 48kHz Stereo 16-bit PCM
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
tmp_path = tmp.name
|
|
with wave.open(tmp_path, 'wb') as wav_file:
|
|
wav_file.setnchannels(2)
|
|
wav_file.setsampwidth(2)
|
|
wav_file.setframerate(48000)
|
|
wav_file.writeframes(buffer_data["data"])
|
|
|
|
# Transcription
|
|
text = await self.whisper.transcribe(tmp_path)
|
|
|
|
if text and len(text.strip()) > 3:
|
|
logger.debug(f"◈ Logged Voice Insight: {buffer_data['name']} -> {text}")
|
|
self.session_buffer.append({
|
|
"user": buffer_data["name"],
|
|
"text": text,
|
|
"ts": time.time()
|
|
})
|
|
|
|
# NEW: Real-time Voice Trigger Detection
|
|
trigger_names = ["bêta", "beta", "béta", "béat", "vêta", "veta"]
|
|
if any(name in text.lower() for name in trigger_names):
|
|
logger.info(f"◈ SYSTEM: Voice trigger MATCH detected in VOC: '{text}'")
|
|
|
|
logger.info(f"◈ SYSTEM: Launching voice interaction bridge for {buffer_data['name']}")
|
|
# We run it in a task to not block the voice processing loop
|
|
# Note: we pass None for channel since bot.py will now use DMs
|
|
self.bot.loop.create_task(
|
|
self.bot.handle_voice_interaction(
|
|
user_name=buffer_data["name"],
|
|
user_id=u_id,
|
|
content=text,
|
|
channel=None
|
|
)
|
|
)
|
|
|
|
# Cleanup
|
|
try: os.remove(tmp_path)
|
|
except: pass
|
|
|
|
async def generate_session_report(self, advanced: bool = False, custom_buffer=None):
|
|
"""Analyze accumulated transcriptions and send a clinical summary.
|
|
If advanced=True, uses the heavy LLM for a deep analysis.
|
|
"""
|
|
buffer = custom_buffer if custom_buffer is not None else self.session_buffer
|
|
|
|
if not buffer:
|
|
return "Aucune donnée vocale significative n'a été capturée durant cette session."
|
|
|
|
# ANALYSE DE TENSION (Mots-clés ou BSI bas)
|
|
is_tense = False
|
|
conflit_keywords = ["fdp", "tg", "nique", "pute", "connard", "merde", "salope"]
|
|
|
|
tense_count = 0
|
|
for item in buffer:
|
|
if any(k in item['text'].lower() for k in conflit_keywords):
|
|
tense_count += 1
|
|
|
|
if tense_count > 3: # Seuil de tension
|
|
is_tense = True
|
|
self.bot.metrics["daily_tense_sessions"] = self.bot.metrics.get("daily_tense_sessions", 0) + 1
|
|
logger.warning(f"◈ SYSTEM: Tense voice session detected in {self.current_channel}")
|
|
|
|
# Concatenate for analysis
|
|
full_transcript = "\n".join([f"{item['user']}: {item['text']}" for item in buffer])
|
|
channel_name = self.current_channel.name if self.current_channel else "N/A"
|
|
duration = int((time.time() - (self.session_start if hasattr(self, 'session_start') else time.time())) / 60)
|
|
|
|
if not advanced:
|
|
# Rapport périodique simple
|
|
report = [
|
|
f"**◈ VOICE SESSION STATUS (Périodique) ◈**",
|
|
f"Salon : `{channel_name}`",
|
|
f"Durée actuelle : `{duration} minutes`",
|
|
f"Extraits récents :\n"
|
|
]
|
|
# On prend les 10 derniers segments
|
|
for item in self.session_buffer[-10:]:
|
|
report.append(f"- **{item['user']}** : \"{item['text'][:100]}\"")
|
|
return "\n".join(report)
|
|
|
|
# RAPPORT AVANCÉ (Fin de session) via HEAVY LLM
|
|
prompt = (
|
|
f"PROTOCOLE D'ANALYSE VOCALE FINALE\n"
|
|
f"Salon : {channel_name}\n"
|
|
f"Durée totale : {duration} minutes\n"
|
|
f"Flux de données capturé :\n---\n{full_transcript}\n---\n\n"
|
|
f"VOTRE MISSION :\n"
|
|
f"En tant que Bêta, analysez l'intégralité de ce flux. Produisez un rapport clinique structuré incluant :\n"
|
|
f"1. RÉSUMÉ EXÉCUTIF (Synthèse des échanges)\n"
|
|
f"2. IDENTIFICATION DES SUJETS (Comportements et attitudes)\n"
|
|
f"3. POINTS DE VIGILANCE (Alertes Relevant ou insights stratégiques)\n"
|
|
f"4. CONCLUSION (État de l'écosystème)\n\n"
|
|
f"IMPORTANT: Pour cette mission spécifique, produisez un rapport en TEXTE BRUT structuré. "
|
|
f"IGNOREZ la consigne habituelle de format JSON. Ne mettez AUCUN bloc JSON."
|
|
f"Le ton doit être froid, professionnel et factuel."
|
|
)
|
|
|
|
try:
|
|
logger.info(f"◈ SYSTEM: Generating advanced report for {channel_name} using Heavy LLM...")
|
|
|
|
# Utilisation du manager LLM du bot
|
|
payload = {
|
|
"model": self.bot.ollama_model,
|
|
"prompt": prompt,
|
|
"system": self.bot.system_prompt,
|
|
"stream": False
|
|
}
|
|
|
|
# Note: call_ollama returns the accumulated string
|
|
analysis = await self.bot.llm.call_ollama(payload)
|
|
|
|
# Nettoyage si jamais l'IA renvoie du JSON superflu (Bêta n'est pas censé mais on sait jamais)
|
|
json_str = self.bot.llm.extract_json_actions(analysis)
|
|
if json_str:
|
|
try:
|
|
data = json.loads(json_str)
|
|
if isinstance(data, list): data = data[0]
|
|
analysis = data.get('response', analysis)
|
|
except: pass
|
|
|
|
return analysis
|
|
|
|
except Exception as e:
|
|
logger.error(f"◈ SYSTEM: Failed to generate advanced voice report: {e}")
|
|
return f"ERREUR ANALYSE IA GÉNÉRALE : {e}\n\n[TRANSCRIPT DE SECOURS]\n{full_transcript[:1800]}..."
|
|
|
|
async def leave_current(self, send_report=True):
|
|
"""Disconnect immediately and handle report generation in the background."""
|
|
if not self.active_connections and not self.current_channel:
|
|
return
|
|
|
|
# 1. Capture current session data for background reporting
|
|
captured_buffer = list(self.session_buffer) if self.session_buffer else []
|
|
was_monitoring = self.is_monitoring
|
|
|
|
# 2. CLEAR STATE IMMEDIATELY (Responsiveness)
|
|
self.session_buffer = []
|
|
self.is_monitoring = False
|
|
if self._monitoring_task:
|
|
self._monitoring_task.cancel()
|
|
|
|
# 3. DISCONNECT IMMEDIATELY
|
|
for guild_id, vc in list(self.active_connections.items()):
|
|
try:
|
|
await vc.disconnect()
|
|
except Exception as e:
|
|
logger.error(f"Error disconnecting from voice: {e}")
|
|
del self.active_connections[guild_id]
|
|
|
|
self.current_channel = None
|
|
logger.info("◈ SYSTEM: Voice Connection Terminated (Resource Preservation)")
|
|
|
|
# 4. Background Report Generation
|
|
if was_monitoring and send_report and captured_buffer:
|
|
# We use a separate task to avoid blocking the caller (who might be waiting to join another channel)
|
|
self.bot.loop.create_task(self._process_background_report(captured_buffer))
|
|
|
|
async def _process_background_report(self, buffer):
|
|
"""Internal helper to generate report without blocking the voice client."""
|
|
try:
|
|
# Temporarily restore buffer for the report generation call
|
|
# Note: generate_session_report uses self.session_buffer, so we might need to adapt it
|
|
# or pass the buffer to it.
|
|
# Let's check generate_session_report signature.
|
|
report_text = await self.generate_session_report(advanced=True, custom_buffer=buffer)
|
|
|
|
# 1. Envoi au canal d'introspection
|
|
await self.bot.introspection_log("FINAL VOICE SESSION ANALYSIS", report_text, discord.Color.blue())
|
|
|
|
# 2. Envoi direct aux Admins
|
|
for admin_id in self.bot.admin_ids:
|
|
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
|
|
if admin:
|
|
await self.bot.messaging.send_embed(admin, "FINAL VOICE SESSION ANALYSIS", report_text, color=discord.Color.blue())
|
|
except Exception as e:
|
|
logger.error(f"◈ SYSTEM: Background report generation failed: {e}")
|
|
await self.bot.introspection_log(
|
|
"VOICE TERMINATION",
|
|
"Connexion vocale terminée. Libération des ressources audio.",
|
|
discord.Color.dark_grey()
|
|
)
|