"""Main Superviseur Bot class - refactored for better organization.""" import discord from discord.ext import commands import aiohttp import asyncio import os import time import json import logging import re from typing import Dict, Optional from .utils import format_mentions_in_text, sanitize_guild_name from .permissions import WhitelistManager, check_is_admin, get_permissions_info from .messaging import MessagingManager from .context import build_context_for_message from .llm import LLMManager from .redis_worker import RedisWorker from .commands import setup_commands from .voice import VoiceManager from .voice import VoiceManager from .dispatcher import AssetDispatcher from .tasks import TaskManager from .action_router import ActionRouter from .heuristic import HeuristicsManager import datetime import pytz from ia.memoire import add_interaction, build_context_for_model, flush_all, MEMORY_DIR from commandes.autres.config import config from commandes.autres.logger import action_logger from commandes.security.auto_moderation import moderate_message class MockMessage: """Mock discord.Message for voice-triggered interactions.""" def __init__(self, author, channel, content, guild=None): self.author = author self.channel = channel self.content = content self.guild = guild or (channel.guild if hasattr(channel, 'guild') else None) self.attachments = [] self.mentions = [] self.channel_mentions = [] self.role_mentions = [] self.jump_url = "" async def reply(self, content, **kwargs): """Mock reply - sends a message to the channel/user instead.""" # For vocal interaction, we don't need a prefix in DM as it's a private chat is_dm = isinstance(self.channel, (discord.DMChannel, discord.User, discord.Member)) prefix = "" if is_dm else f"**{self.author.display_name}** (Vocal) : " return await self.channel.send(f"{prefix}{content}", **kwargs) logger = logging.getLogger('Superviseur') class Superviseur(commands.Bot): """Main bot class for Superviseur Discord bot.""" def __init__( self, guild_id: int, llama_model, system_prompt: str, command_prefix: str, intents: discord.Intents, temperature: float = 0.4, model_loaded: bool = True ): super().__init__(command_prefix=command_prefix, intents=intents) # Configuration self.guild_id = guild_id self.llama_model = llama_model self.system_prompt = system_prompt self.temperature = temperature self.model_loaded = model_loaded # Identity self.system_name = "Bêta" admins_env = os.getenv("ADMIN_IDS", "") self.admin_ids = [int(i.strip()) for i in admins_env.split(",") if i.strip().isdigit()] self.introspection_channel_id = int(os.getenv("INTROSPECTION_CHANNEL_ID", 0)) # Assets (Staff) assets_env = os.getenv("PRIMARY_ASSETS_IDS", "") self.asset_ids = [int(i.strip()) for i in assets_env.split(",") if i.strip().isdigit()] # Spam Prevention self.insight_cooldowns = {} # {user_id: timestamp} self.insight_cooldown_duration = 1800 # 30 minutes self.insight_threshold = 5 self.ignored_words = {"salut", "cc", "hello", "ça va", "ca va", "bjr", "slt", "yo", "re", "ok"} self.monitoring_cooldowns = {} # Caches self.channels_list_cache = {} self.server_context_cache = {} self._module_cache: Dict[str, object] = {} # HTTP session self.http_session: Optional[aiohttp.ClientSession] = None # Concurrency control self.max_concurrent_requests = int(os.environ.get("SUPERVISEUR_MAX_CONCURRENT", 4)) self._request_semaphore = asyncio.Semaphore(self.max_concurrent_requests) # Metrics self.metrics = { "total_llm_requests": 0, "total_llm_success": 0, "total_llm_errors": 0, "total_llm_time": 0.0, } # Redis self.aioredis = None try: import redis.asyncio as aioredis self.aioredis = aioredis except Exception: pass self.redis_worker: Optional[RedisWorker] = None # Whitelist self.whitelist_manager = WhitelistManager() # Messaging self.messaging = MessagingManager(self) # Disable command prefix for natural language focus self.command_prefix = "DISABLED_PREFIX_FOR_NL" # Voice Management self.voice_manager = VoiceManager(self) # LLM self.llm = LLMManager( llama_model=llama_model, temperature=temperature ) # Dispatcher & Sub-managers self.dispatcher = AssetDispatcher(self) self.task_manager = TaskManager(self) self.action_router = ActionRouter(self) self.heuristics_manager = HeuristicsManager(self) # Logging avec fuseau horaire local (Europe/Paris) self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris")) def local_time_converter(*args): return datetime.datetime.now(self.timezone).timetuple() logging.Formatter.converter = local_time_converter logging.basicConfig( level=logging.INFO, # Augmenté à INFO par défaut pour le root format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # Sourdine sur les logs Discord trop bavards (RTCP, Gateway) logging.getLogger('discord').setLevel(logging.WARNING) logging.getLogger('discord.voice_state').setLevel(logging.WARNING) logging.getLogger('discord.gateway').setLevel(logging.WARNING) logging.getLogger('httpx').setLevel(logging.WARNING) logging.getLogger('huggingface_hub').setLevel(logging.WARNING) try: logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING) except: pass # ==================== Utility Methods ==================== async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()): """Internal telemetry log - bypassing AI to preserve resources.""" if not self.introspection_channel_id: return channel = self.get_channel(self.introspection_channel_id) or await self.fetch_channel(self.introspection_channel_id) if channel: await self.messaging.send_embed( channel, f"INTROSPECTION: {title}", description, color=color ) def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]: """Get user's whitelist level.""" return self.whitelist_manager.get_user_level(user_id, user_name) def has_whitelist_permission( self, user_id: int, required_level: str, user_name: Optional[str] = None ) -> bool: """Check whitelist permission.""" return self.whitelist_manager.has_permission(user_id, required_level, user_name) # ==================== Event Handlers ==================== async def on_ready(self): """Called when bot is ready.""" print(f'Logged in as {self.user}') # Register commands await setup_commands(self) # Create memory directories import os for guild in self.guilds: sanitized_name = sanitize_guild_name(guild.name) guild_dir = os.path.join(MEMORY_DIR, sanitized_name) os.makedirs(guild_dir, exist_ok=True) print(f'Memory directory created for guild {guild.name}: {guild_dir}') # Setup HTTP session (not needed for llama-cpp-python, but kept for compatibility) pass # Setup Redis # Start background tasks await self.voice_manager.initialize() self.task_manager.start_all() async def on_member_join(self, member): """Handle member join.""" welcome_channel_id = config.get_welcome_channel(member.guild.id) if welcome_channel_id: channel = member.guild.get_channel(welcome_channel_id) if channel: embed = discord.Embed( title="Bienvenue !", description=f"Bienvenue {member.mention} sur {member.guild.name} !", color=discord.Color.green() ) embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url) await channel.send(embed=embed) async def on_member_remove(self, member): """Handle member leave.""" goodbye_channel_id = config.get_goodbye_channel(member.guild.id) if goodbye_channel_id: channel = member.guild.get_channel(goodbye_channel_id) if channel: embed = discord.Embed( title="Au revoir !", description=f"{member.name} a quitté {member.guild.name}.", color=discord.Color.red() ) embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url) await channel.send(embed=embed) async def on_message(self, message): """Handle incoming messages.""" # Global activity tracking (Placeholder for future privacy-compliant metrics if needed) pass logger.info(f"Message received from {message.author} in {message.channel}") if message.author == self.user: return if isinstance(message.channel, discord.DMChannel): processed = await self.dispatcher.handle_dm_response(message) if processed: return # Si ce n'est pas une réponse d'entretien, on traite comme une interaction IA normale await self._handle_ai_interaction(message) return # Auto-moderation if message.guild: moderated = await self._handle_moderation(message) if moderated: return # DEBUG: Print flow info logger.info(f"◈ DEBUG: on_message | Author: {message.author.id} | Bot: {self.user.id if self.user else 'None'}") if not self._should_respond(message): logger.info(f"◈ DEBUG: _should_respond is False. Entering Heuristic Monitoring.") # Background Heuristic Analysis (Silent monitoring) await self._handle_heuristic_monitoring(message) return logger.info(f"◈ SYSTEM: Interaction directe détectée pour '{message.author.display_name}'") # 100% Natural Language Interaction (Commands disabled) await self._handle_ai_interaction(message) # ==================== Cache Management ==================== def _invalidate_caches(self, guild_id: int): """Invalidate caches for a guild.""" self.channels_list_cache.pop(guild_id, None) self.server_context_cache.pop(guild_id, None) # Keep caches updated on changes async def on_guild_channel_create(self, channel): self._invalidate_caches(channel.guild.id) async def on_guild_channel_delete(self, channel): self._invalidate_caches(channel.guild.id) async def on_voice_state_update(self, member, before, after): """Handle voice state changes for cleanup if needed.""" # On-demand only, so no automatic connection logic here. pass async def on_guild_channel_update(self, before, after): self._invalidate_caches(before.guild.id) async def on_member_join(self, member): self._invalidate_caches(member.guild.id) async def on_member_remove(self, member): self._invalidate_caches(member.guild.id) async def on_guild_role_create(self, role): self._invalidate_caches(role.guild.id) async def on_guild_role_delete(self, role): self._invalidate_caches(role.guild.id) async def on_guild_role_update(self, before, after): self._invalidate_caches(before.guild.id) # ==================== Heuristic Engine (Risk Assessment) ==================== # ==================== Moderation ==================== async def _handle_moderation(self, message): """Handle auto-moderation.""" return await moderate_message(self, message) async def _handle_heuristic_monitoring(self, message): """Perform background analysis without replying unless critical.""" # Filtrage rapide des messages triviaux (Triage niveau 1) content_low = message.content.lower().strip() if len(content_low) < 3 or content_low in self.ignored_words: return should_analyze = self.heuristics_manager.assess_risk(message) if not should_analyze: return logger.info(f"◈ SYSTEM: Analyse heuristique déclenchée pour '{message.author.display_name}'") # 2. Context Injection (Channel History) # Fetch last 10 messages for better proactive analysis context_msgs = [] try: async for m in message.channel.history(limit=10, before=message): if not m.content: continue context_msgs.append(f"{m.author.display_name}: {m.content}") except Exception: pass # Ignore permission errors context_str = "\n".join(reversed(context_msgs)) # Instruction spécifique pour le mode monitoring avec contexte monitoring_flag = ( f"\n[MODE: SURVEILLANCE HEURISTIQUE - NE RÉPONDEZ PAS SAUF SI ALERT/INSIGHT DÉTECTÉ]\n" f"[CONTEXTE DU SALON (5 derniers messages)]:\n{context_str}\n" f"[MESSAGE ACTUEL A ANALYSER]:\n{message.content}" ) # We override content in _handle_ai_interaction by passing extra_context # But _prepare_content uses message.content. We'll append this flag. await self._handle_ai_interaction(message, extra_context=monitoring_flag, is_monitoring=True) def _should_respond(self, message) -> bool: """Check if bot should respond to message.""" if message.content.startswith(self.command_prefix): return True if self.user in message.mentions: logger.info("◈ DEBUG: Triggered by standard mentions list") return True # Robust Mention Check (Regex Fallback) bot_id = str(self.user.id) logger.info(f"◈ DEBUG: Checking mention for ID {bot_id} in '{message.content[:30]}...'") if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content: logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)") return True if message.reference and message.reference.resolved: if message.reference.resolved.author == self.user: logger.info("◈ DEBUG: Triggered by reply") return True return False # ==================== AI Interaction ==================== async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False): """Handle AI interaction.""" logger.info(f"AI interaction triggered (Monitoring: {is_monitoring})") # ... (same caching logic) if message.guild: perms = message.author.guild_permissions if check_is_admin(perms): self._invalidate_caches(message.guild.id) # Build context author_perms = message.author.guild_permissions if message.guild else None # Détection du rôle pour le prompt if message.author.id in self.admin_ids: role_name = "ADMIN" elif message.author.id in self.asset_ids: role_name = "ASSET" else: role_name = "SUJET" permissions_info = get_permissions_info( author_perms, self.get_user_level(message.author.id) or 'none', role_name=role_name ) channels_list, server_context = build_context_for_message( message.guild, self.channels_list_cache, self.server_context_cache ) user_id = str(message.author.id) guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct" history = await build_context_for_model(user_id, max_recent=12, guild_id=guild_name) or "" # Prepare content content = self._prepare_content(message) + extra_context # Build payload payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message) # Execute request async with self._request_semaphore: # Decider quel modèle utiliser # Modèle HEAVY pour Admin/Asset ou si on lui parle directement # Modèle FAST pour le monitoring pur (unloads the server) is_direct = self._should_respond(message) or message.author.id in self.admin_ids # For llama-cpp-python, we don't need to specify different models in payload # The model is already set in the LLMManager initialization async def run_call(): if self.redis_worker: resp = await self.redis_worker.enqueue_job(payload, timeout=605) # 10 minutes + 5 seconds return resp.get("result", "") if resp else "" else: return await self.llm.call_llama(payload) try: self.metrics["total_llm_requests"] += 1 start = time.perf_counter() if is_monitoring: # Pas d'indicateur de frappe en mode surveillance accumulated_reply = await run_call() else: async with message.channel.typing(): accumulated_reply = await run_call() # --- ReAct Loop for Memory (READ_LOGS) --- if "READ_LOGS" in accumulated_reply: logger.info("◈ SYSTEM: Memory access requested (READ_LOGS). Fetching logs and re-prompting...") logs_context = self.get_recent_logs() # Update payload with logs extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}" payload["prompt"] += extra_memory_context # Re-run the call with memory if is_monitoring: accumulated_reply = await run_call() else: async with message.channel.typing(): accumulated_reply = await run_call() # ---------------------------------------- await self._process_response(accumulated_reply, message, is_monitoring=is_monitoring) dur = time.perf_counter() - start self.metrics["total_llm_success"] += 1 self.metrics["total_llm_time"] += dur except Exception as e: try: dur = time.perf_counter() - start self.metrics["total_llm_errors"] += 1 self.metrics["total_llm_time"] += dur except: pass logger.error(f"LLM error: {e}") if not is_monitoring: await self.messaging.reply_with_limit(message, f"Erreur: {e}") async def handle_voice_interaction(self, user_name: str, user_id: int, content: str, channel): """Bridge for real-time voice-to-text interaction.""" try: # Create a mock objects/context for internal processing mock_author = self.get_user(user_id) or await self.fetch_user(user_id) if not mock_author: logger.error(f"◈ SYSTEM: Voice trigger FAIL - User {user_id} ({user_name}) introuvable.") return logger.info(f"◈ SYSTEM: Voice trigger processing for {user_name}: '{content}'") # Role detection role_name = "ADMIN" if user_id in self.admin_ids else "ASSET" if user_id in self.asset_ids else "SUJET" # REFINEMENT: Only respond to Admins and Assets (Staff) if role_name == "SUJET": logger.info(f"◈ SYSTEM: Voice trigger ignored for SUJET {user_name}") return # Specialized context for vocal origin - FORCE PLAIN TEXT DIALOGUE vocal_context = ( f"\n[MODE: DIALOGUE VOCAL DIRECT - PRIORITÉ ABSOLUE]\n" f"L'interlocuteur {user_name} ({role_name}) vous parle à l'oral.\n" f"RÈGLES CRITIQUES :\n" f"1. RÉPONDEZ DIRECTEMENT en langage naturel. NE PAS UTILISER DE FORMAT JSON.\n" f"2. INTERDICTION DE GÉNÉRER DES TÂCHES, INSIGHTS OU ALERTES.\n" f"3. Votre réponse sera envoyée en MESSAGE PRIVÉ à cet utilisateur.\n" f"4. Soyez concis, clinique et engagez la conversation.\n" f"REMARQUE : Ignorez la règle 'JSON UNIQUEMENT' pour cette transmission.\n" ) # Build context author_perms = mock_author.guild_permissions if hasattr(mock_author, 'guild') else None permissions_info = get_permissions_info(author_perms, self.get_user_level(user_id) or 'none', role_name=role_name) # We don't need channel guild if we respond in DM channels_list, server_context = build_context_for_message(None, self.channels_list_cache, self.server_context_cache) # Prepare history (DM history is better here) history = await build_context_for_model(str(user_id), max_recent=12) or "" # Create MockMessage targeting the USER (for DM response) mock_msg = MockMessage(author=mock_author, channel=mock_author, content=content) # Prepare a specialized system prompt for vocal mode (no JSON requirement) vocal_system_prompt = self.system_prompt.split("FORMAT DE RÉPONSE STRICT")[0] vocal_system_prompt += ( "\n[PROTOCOLE VOCAL ACTIF]\n" "RÉPONDEZ EXCLUSIVEMENT EN TEXTE PUR. INTERDICTION TOTALE DE GÉNÉRER DU JSON OU DES MARQUEURS.\n" "Soyez concis, clinique et engagez une conversation fluide en Message Privé avec l'utilisateur." ) # Prepare payload with specialized prompt payload = self.llm.build_payload( prompt=f"{permissions_info}\nNom d'utilisateur : {mock_author.display_name}{server_context}{channels_list}\n{history}\n\nUser: {content}", system_prompt=vocal_system_prompt, vision_model=False ) async with self._request_semaphore: try: analysis = await self.llm.call_llama(payload) if not analysis: logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.") return # --- ReAct Loop for Memory (READ_LOGS) in Voice Mode --- if "READ_LOGS" in analysis: logger.info("◈ SYSTEM: Voice memory access requested (READ_LOGS). Fetching logs...") logs_context = self.get_recent_logs() extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}" payload["prompt"] += extra_memory_context analysis = await self.llm.call_llama(payload) # ---------------------------------------------------- logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...") # Use standard _process_response with mock_msg await self._process_response(analysis, mock_msg) logger.info(f"◈ SYSTEM: Voice trigger response processed for {user_name}.") except Exception as e: logger.error(f"◈ SYSTEM: Error during voice trigger AI processing: {e}") except Exception as e: logger.error(f"◈ SYSTEM: Error in handle_voice_interaction bridge: {e}") def _prepare_content(self, message) -> str: """Prepare message content for LLM.""" if not message: return "" content = message.content # Replace channel mentions <#123> -> #general for channel in message.channel_mentions: content = content.replace(f'<#{channel.id}>', f'#{channel.name}') # Replace role mentions <@&123> -> @admin for role in message.role_mentions: content = content.replace(f'<@&{role.id}>', f'@{role.name}') # Replace user mentions <@123> or <@!123> -> @User for mention in message.mentions: if mention.id != self.user.id: content = content.replace(f'<@{mention.id}>', f'@{mention.display_name}') content = content.replace(f'<@!{mention.id}>', f'@{mention.display_name}') # Remove bot mention content = content.lstrip(f'<@{self.user.id}>').lstrip(f'<@!{self.user.id}>').strip() # Handle attachments if message.attachments: for attachment in message.attachments: if attachment.content_type and attachment.content_type.startswith('image/'): content += f" [Image: {attachment.filename}]" if content else f"[Image: {attachment.filename}]" else: content += f" [Fichier: {attachment.filename}]" return content def get_recent_logs(self, limit: int = 15) -> str: """Read and format recent action logs for context.""" log_path = "data/actions.log" if not os.path.exists(log_path): return "" try: with open(log_path, 'r', encoding='utf-8') as f: lines = f.readlines() last_lines = lines[-limit:] formatted_logs = [] for line in last_lines: if "ACTION:" in line: try: parts = line.split("ACTION:", 1) if len(parts) > 1: json_str = parts[1].strip() data = json.loads(json_str) timestamp = data.get("timestamp", "").split("T")[1].split(".")[0] action = data.get("action", "UNKNOWN") user = data.get("user", "System") params = data.get("params", {}) success = data.get("success", True) # Format details based on action type details = "" if action == 'SEND_MESSAGE': details = f"(to: {params.get('channel_id') or params.get('user_id')}, content: {params.get('content', '')[:20]}...)" elif 'CHANNEL' in action: # Handle channel actions (list of channels) items = params.get('channels', []) if items: names = [i.get('channel_name', 'unknown') for i in items] details = f"(channels: {', '.join(names)})" elif 'ROLE' in action: items = params.get('roles', []) if items: names = [i.get('role_name', 'unknown') for i in items] details = f"(roles: {', '.join(names)})" elif action in ['KICK', 'BAN', 'WARN', 'MUTE', 'TIMEOUT']: target = params.get('user_id', 'unknown') reason = params.get('reason', 'no reason') details = f"(target: {target}, reason: {reason})" elif action == 'PURGE': purges = params.get('purges', []) if purges: p = purges[0] details = f"(channel: {p.get('channel_name')}, amount: {p.get('amount')})" status = "✅" if success else "❌" formatted_logs.append(f"[{timestamp}] {status} {action} {details} by {user}") except Exception: continue if not formatted_logs: return "" return "\n[MÉMOIRE DES ACTIONS RÉCENTES (VÉRITÉ)]\n" + "\n".join(formatted_logs) + "\n" except Exception as e: logger.error(f"Error reading actions log: {e}") return "" def _build_payload( self, permissions_info: str, server_context: str, channels_list: str, history: str, content: str, message ) -> dict: """Build LLM payload.""" user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})" # Removed redundant self.system_prompt as it's passed as system_prompt in build_payload prompt = f"{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}" # For llama-cpp-python, we don't need to check for vision models in the same way # Vision support would need to be handled differently with llama-cpp-python vision_model = False # Simplified for now return self.llm.build_payload( prompt=prompt, system_prompt=self.system_prompt, vision_model=vision_model, attachments=message.attachments ) async def _process_response(self, accumulated_reply: str, message, is_monitoring=False): """Process LLM response and prevent JSON leakage.""" json_str = self.llm.extract_json_actions(accumulated_reply) action_executed = False # Nettoyer la réponse pour enlever les blocs JSON et les marqueurs internes clean_reply = accumulated_reply # 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique" if json_str: clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE).strip() # 2. Nettoyage des éventuels backticks de code et résidus de JSON clean_reply = clean_reply.replace('```json', '').replace('```', '').strip() # 3. Filtrer les marqueurs internes fréquents (Leak prevention) markers_to_remove = [ "[Public]", "[Relevant]", "[Irrelevant]", "[Alerte]", "[Insight]", "[CRITICITÉ", "[MENACE", "[RISQUE", "[DÉTECTION", "[ANALYSE", "[ACTION" ] for marker in markers_to_remove: if marker in clean_reply: if marker.startswith("["): # Recherche de la fin du tag ] pour les tags ouverts start_idx = clean_reply.find(marker) end_idx = clean_reply.find("]", start_idx) if end_idx != -1: clean_reply = clean_reply[:start_idx] + clean_reply[end_idx+1:] else: clean_reply = clean_reply.replace(marker, "") else: clean_reply = clean_reply.replace(marker, "") # 4. Supprimer les résidus JSON rémanents avec un regex plus large clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE) # 5. Supprimer les lignes vides en trop et les espaces multiples clean_reply = re.sub(r'\n{3,}', '\n\n', clean_reply) clean_reply = clean_reply.strip() is_explicit_none = False if json_str: try: actions = json.loads(json_str) if isinstance(actions, dict): actions = [actions] for action_data in actions: if action_data.get('action', '').upper() == 'NONE' and not action_data.get('response'): is_explicit_none = True # Traitement Alerte/Insight (Toujours en DM) await self._handle_beta_signals(action_data, message) # Exécution des actions système (Silence forcé en monitoring pour action: NONE) res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring) if res: action_executed = True except Exception as e: logger.error(f"Error processing actions: {e}") # Store interaction (Even if silent, we store the input for better context next time) user_id = str(message.author.id) guild_id = sanitize_guild_name(message.guild.name) if message.guild else None await add_interaction(user_id, message.channel.id, message.content, clean_reply if not is_monitoring else "", guild_id) # En mode monitoring, on ne répond PAS au canal public sauf si l'IA l'a explicitement demandé via une action if is_monitoring and not action_executed: return # Si aucune action n'a envoyé de message (comme SEND_MESSAGE ou action: NONE avec response), # et qu'on n'est pas en monitoring, on envoie le texte nettoyé. if not action_executed: if clean_reply: await self.messaging.reply_with_limit(message, format_mentions_in_text(clean_reply)) elif not is_monitoring and not is_explicit_none: # Log the raw response to understand why parsing failed raw_snippet = accumulated_reply.strip() logger.warning(f"◈ SYSTEM: Failed to parse action or empty response. Raw output: {raw_snippet[:200]}") fallback_msg = "Ma réponse a été filtrée ou est invalide. Pouvez-vous reformuler ?" await self.messaging.reply_with_limit(message, fallback_msg) async def _handle_beta_signals(self, action_data, message): """Handle Alert (Relevant) and Insight (Irrelevant) signals with full context.""" insight = action_data.get('insight') alert = action_data.get('alert') importance = action_data.get('importance', 0) # Préparation du bloc de données structurées context_data = { "author_mention": message.author.mention, "author_name": message.author.display_name, "channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM', "content": f"*{message.content[:250]}{'...' if len(message.content) > 250 else ''}*", "jump_url": message.jump_url if message.jump_url else None } # 1. ALERT -> L'ADMIN (Relevant) - Triage Critique if alert and self.admin_ids: # Formatage de l'alerte pour l'Admin (markdown propre) admin_trigger_text = ( f"{alert}\n\n" f"**◈ CONTEXTE DÉTECTION ◈**\n" f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n" f"- **Salon** : {context_data['channel_mention']}\n" + (f"- **Lien direct** : [Accéder au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale Directe") ) for admin_id in self.admin_ids: admin = self.get_user(admin_id) or await self.fetch_user(admin_id) if admin: await self.messaging.send_embed( admin, f"ALERTE RELEVANT (Priorité {importance}/10)", admin_trigger_text, color=discord.Color.red() ) # 2. INSIGHT -> STAFF (Irrelevant) - Triage Opérationnel if insight and self.asset_ids: if alert: logger.warning(f"◈ ALERT RELEVANT: {alert}") else: if importance > 7: logger.info(f"◈ INSIGHT IRRELEVANT (High Importance {importance}): {insight[:50]}...") # Utilisation du Dispatcher avec données structurées await self.dispatcher.dispatch_insight(insight, importance, message.guild, context_data) async def _execute_actions(self, action_data, message, final_content, is_monitoring=False) -> bool: """Execute JSON actions and provide feedback.""" action_executed = False async def process_item(item): nonlocal action_executed if not isinstance(item, dict) or 'action' not in item: return action = item['action'] resp = item.get('response', "").strip() if resp and not is_monitoring: await self.messaging.reply_with_limit(message, format_mentions_in_text(resp)) action_executed = True if action != 'NONE': success, result = await self.action_router.execute_action(action, item, message) action_logger.log_action(action, str(message.author), str(message.guild), item, success, result) # Feedback to user for systemic actions if not in monitoring if not is_monitoring: if success: if result: # Some actions return a success string await self.messaging.reply_with_limit(message, f"◈ SUCCESS: {result}") else: # Default success feedback for important actions if they are silent if action in ['JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER']: confirmations = { 'JOIN_VOICE': "Signal reçu. Je rejoins le salon vocal.", 'LEAVE_VOICE': "Opération terminée. Je quitte le salon vocal.", 'FORGET_USER': "Protocole d'effacement terminé. Vos données ont été supprimées." } await self.messaging.reply_with_limit(message, f"◈ {confirmations.get(action, 'Action exécutée avec succès.')}") else: # Error feedback err_msg = result or "Une erreur est survenue lors de l'exécution." await self.messaging.reply_with_limit(message, f"◈ ERREUR: {err_msg}") action_executed = True if isinstance(action_data, list): executed = set() for item in action_data: key = json.dumps(item, sort_keys=True) if key in executed: continue executed.add(key) await process_item(item) elif isinstance(action_data, dict): await process_item(action_data) return action_executed # ==================== Metrics & Cleanup ==================== def get_metrics(self) -> Dict: """Return runtime metrics.""" merged = {**self.metrics} try: from ia.memoire import get_metrics as _m mem = _m() merged["memory"] = mem except Exception: merged["memory"] = {} if self.redis_worker: merged["queue_pending"] = len(self.redis_worker._pending_jobs) return merged async def close(self): """Clean shutdown.""" try: await flush_all() except Exception: logger.exception("Error flushing memories") await self.llm.close_session() if self.redis_worker: await self.redis_worker.close() await super().close()