import discord import logging import asyncio import os import time from typing import Dict, Optional, List, Any from .whisper_worker import WhisperWorker logger = logging.getLogger('Beta') # 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 evaluate_and_connect(self, guild): """Analyze voice channels in guild and connect if strategically relevant. Only connects if the bot is not already connected to ANY voice channel.""" # Prevent connecting to multiple guilds simultaneously if self.current_channel is not None: return # Also check if any voice client is already active for this guild if guild.voice_client is not None: return best_channel = None max_members = 0 for vc in guild.voice_channels: # We count actual members, excluding bots members = [m for m in vc.members if not m.bot] if len(members) > max_members: max_members = len(members) best_channel = vc if best_channel and max_members >= 1: logger.info(f"◈ TACTICAL EVALUATION: Target acquired: '{best_channel.name}' in {guild.name}") await self.join_channel(best_channel) async def join_channel(self, channel: discord.VoiceChannel): """Securely join a voice channel with permission checking.""" # Prevent joining if already connected somewhere if self.current_channel is not None: logger.debug(f"◈ Already connected to '{self.current_channel.name}', ignoring join request for '{channel.name}'.") return try: # Physical authorization check me = channel.guild.me perms = channel.permissions_for(me) if not perms.connect or not perms.view_channel: logger.info(f"Permissions vocales insuffisantes pour '{channel.name}' (CONNECT: {perms.connect}, VIEW: {perms.view_channel}). Skip.") 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, timeout=10) 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: error_str = str(e) # Erreurs connues de Discord (permissions, region, etc.) → logger proprement if "4017" in error_str or "403" in error_str or "Permission" in error_str: logger.info(f"Connexion vocale impossible sur '{channel.name}': {error_str.split(chr(10))[0]}") elif "4049" in error_str or "No audio" in error_str: logger.debug(f"Voice connection issue on '{channel.name}': {error_str[:100]}") else: logger.warning(f"Échec connexion vocale '{channel.name}': {error_str[:200]}") 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 = self.bot.llm.build_payload( prompt=prompt, system_prompt=self.bot.system_prompt, force_json=False ) analysis = await self.bot.llm.call_llama(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() )