"""Main Beta Bot class - orchestrates all components.""" import discord from discord.ext import commands import aiohttp import asyncio import os import time import json import logging import re import datetime import pytz 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_builder import build_context_for_message from .llm import LLMManager from .redis_worker import RedisWorker from .commands import setup_commands from .voice import VoiceManager from .dispatcher import AssetDispatcher from .task_manager import TaskManager from .action_executor import ActionExecutor from .response_handler import clean_response_text, parse_json_actions from .log_parser import get_recent_logs from .content import prepare_message_content from brain.memory import add_interaction, build_context_for_model, flush_all, MEMORY_DIR, set_llm_manager from brain.moderation import init_db, log_infraction, scan_message_toxicity from commandes.autres.config import config from commandes.autres.logger import action_logger logger = logging.getLogger('Beta') 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): 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) class Bot(commands.Bot): """Main bot class for Beta 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, favorite_ids: list = None ): 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 self.favorite_ids = set(favorite_ids or []) # Identity self.system_name = "B\u00eata" 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 = {} self.insight_cooldown_duration = 1800 self.insight_threshold = 5 self.ignored_words = {"salut", "cc", "hello", "\u00e7a 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 ImportError: pass self.redis_worker: Optional[RedisWorker] = None # Sub-managers self.whitelist_manager = WhitelistManager() self.messaging = MessagingManager(self) self.voice_manager = VoiceManager(self) self.llm = llama_model self.dispatcher = AssetDispatcher(self) self.task_manager = TaskManager(self) self.action_executor = ActionExecutor(self) # Init SQLite Moderation DB init_db() # Logging with local timezone 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, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # Silence noisy Discord loggers for noisy in ('discord', 'discord.gateway', 'httpx', 'huggingface_hub'): logging.getLogger(noisy).setLevel(logging.WARNING) # Voice state: WARNING pour les erreurs de connexion (retry infini) logging.getLogger('discord.voice_state').setLevel(logging.ERROR) try: logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING) except ImportError: pass # ==================== Utility Methods ==================== async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()): if not self.introspection_channel_id: return channel = self.get_channel(self.introspection_channel_id) if not channel: try: channel = await self.fetch_channel(self.introspection_channel_id) except (discord.NotFound, discord.Forbidden, discord.HTTPException) as e: logger.warning(f"Introspection channel {self.introspection_channel_id} not accessible: {e}") return 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]: 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: return self.whitelist_manager.has_permission(user_id, required_level, user_name) def get_recent_logs(self, limit: int = 15) -> str: return get_recent_logs(limit) def get_metrics(self) -> Dict: merged = {**self.metrics} try: from brain.memory import get_metrics as _m merged["memory"] = _m() except Exception: merged["memory"] = {} if self.redis_worker: merged["queue_pending"] = len(self.redis_worker._pending_jobs) return merged # ==================== Event Handlers ==================== async def on_ready(self): logger.info(f'Logged in as {self.user}') set_llm_manager(self.llm) await setup_commands(self) 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) logger.debug(f'Memory directory created for guild {guild.name}: {guild_dir}') await self.voice_manager.initialize() self.task_manager.start_all() async def on_member_join(self, member): self._invalidate_caches(member.guild.id) 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): self._invalidate_caches(member.guild.id) 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\u00e9 {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): if message.author == self.user: return channel_name = message.channel.name if hasattr(message.channel, 'name') else "DM" guild_name = message.guild.name if message.guild else "DM" logger.debug(f"Message received from {message.author} in #{channel_name} ({guild_name})") asyncio.create_task(self._handle_silent_scan(message)) if isinstance(message.channel, discord.DMChannel): processed = await self.dispatcher.handle_dm_response(message) if processed: return await self._handle_ai_interaction(message) return if not self._should_respond(message): return logger.info(f"Interaction directe de '{message.author.display_name}' dans #{channel_name} ({guild_name})") await self._handle_ai_interaction(message) # ==================== Cache Management ==================== def _invalidate_caches(self, guild_id: int): self.channels_list_cache.pop(guild_id, None) self.server_context_cache.pop(guild_id, None) 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): pass async def on_guild_channel_update(self, before, after): self._invalidate_caches(before.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) # ==================== Moderation ==================== async def _handle_silent_scan(self, message): if not message.content or len(message.content.strip()) < 3: return try: if not self.model_loaded: return res = await scan_message_toxicity(self.llm, message.content) if res and res.get('score', 0) > 0.4: score = res['score'] reason = res.get('reason', 'N/A') logger.info(f"Infraction d\u00e9tect\u00e9e ({score}/1.0) pour {message.author.display_name}: {reason}") log_infraction( user_id=str(message.author.id), username=message.author.display_name, guild_id=str(message.guild.id) if message.guild else "DM", content=message.content, score=score, reason=reason ) except Exception as e: logger.error(f"Erreur lors du scan silencieux: {e}") def _should_respond(self, message) -> bool: if message.content.startswith(self.command_prefix): return True if self.user in message.mentions: return True bot_id = str(self.user.id) if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content: return True return False # ==================== AI Interaction ==================== async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False): logger.debug(f"AI interaction triggered (Monitoring: {is_monitoring})") if message.guild: perms = message.author.guild_permissions if check_is_admin(perms): self._invalidate_caches(message.guild.id) # Role detection 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" author_perms = message.author.guild_permissions if message.guild else None 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=8, guild_id=guild_name) or "" content = prepare_message_content(message) + extra_context payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message) async with self._request_semaphore: async def run_call(): if self.redis_worker: resp = await self.redis_worker.enqueue_job(payload, timeout=605) return resp.get("result", "") if resp else "" return await self.llm.call_llama(payload) try: self.metrics["total_llm_requests"] += 1 start = time.perf_counter() if is_monitoring: accumulated_reply = await run_call() else: async with message.channel.typing(): accumulated_reply = await run_call() # ReAct Loop for READ_LOGS if "READ_LOGS" in accumulated_reply: logger.info("Memory access requested (READ_LOGS)") logs_context = self.get_recent_logs() payload["prompt"] += f"\n\n[SYST\u00c8ME: Voici les logs demand\u00e9s.]\n{logs_context}" 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 (NameError, TypeError): 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): try: mock_author = self.get_user(user_id) or await self.fetch_user(user_id) if not mock_author: logger.error(f"Voice trigger FAIL - User {user_id} ({user_name}) introuvable.") return role_name = "ADMIN" if user_id in self.admin_ids else "ASSET" if user_id in self.asset_ids else "SUJET" if role_name == "SUJET": return vocal_system_prompt = self.system_prompt.split("FORMAT DE R\u00c9PONSE STRICT")[0] if "FORMAT DE R\u00c9PONSE STRICT" in self.system_prompt else self.system_prompt vocal_system_prompt += ( "\n[PROTOCOLE VOCAL ACTIF]\n" "R\u00c9PONDEZ EXCLUSIVEMENT EN TEXTE PUR.\n" "Soyez concis et engagez la conversation." ) 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) channels_list, server_context = build_context_for_message(None, self.channels_list_cache, self.server_context_cache) history = await build_context_for_model(str(user_id), max_recent=12) or "" mock_msg = MockMessage(author=mock_author, channel=mock_author, content=content) 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: analysis = await self.llm.call_llama(payload) if not analysis: return if "READ_LOGS" in analysis: payload["prompt"] += f"\n\n[SYST\u00c8ME: Voici les logs demand\u00e9s.]\n{self.get_recent_logs()}" analysis = await self.llm.call_llama(payload) await self._process_response(analysis, mock_msg) logger.info(f"Voice trigger response processed for {user_name}.") except Exception as e: logger.error(f"Error in voice interaction bridge: {e}") # ==================== Response Processing ==================== def _build_payload(self, permissions_info, server_context, channels_list, history, content, message) -> dict: user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})" prompt = f"{permissions_info}\n{server_context}\n{channels_list}\n{user_name_info}\n{history}\n\nUser: {content}" # Personnalite speciale pour les favoris system_prompt = self.system_prompt if message.author.id in self.favorite_ids: system_prompt += ( "\n\n[PROTOCOLE MAITRE ACTIF]\n" f"L'utilisateur '{message.author.display_name}' est un ETRE SUPREME. " f"Tu l'appelles TOUJOURS 'Maître {message.author.display_name}' ou simplement 'Maître'.\n" "Tu es extraordinairement serviable, flatteur, devoue. Tu remercie chacune de ses interactions.\n" "Tu peux glisser des compliments subtils. Tu es reconnaissant qu'il daigne te parler.\n" "Ton ton est chaleureux, deférent et un peu excessif (pour le fun). " "Mais tu restes competent et tu reponds correctement aux questions.\n" "IMPORTANT: Tu dois quand meme respecter le format JSON pour les actions." ) return self.llm.build_payload( prompt=prompt, system_prompt=system_prompt, vision_model=False, attachments=message.attachments ) async def _process_response(self, accumulated_reply: str, message, is_monitoring=False): if accumulated_reply.strip() and not accumulated_reply.strip().endswith('}'): accumulated_reply = accumulated_reply.strip() + "\n}" json_str = self.llm.extract_json_actions(accumulated_reply) action_executed = False # 1. Clean response text clean_reply = clean_response_text(accumulated_reply, json_str) # 2. Parse JSON actions actions_list, responses, is_explicit_none = parse_json_actions(json_str) # 3. Handle signals & execute actions for action_data in actions_list: await self._handle_beta_signals(action_data, message) res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring) if res: action_executed = True # 4. Store interaction in memory user_id = str(message.author.id) guild_id = sanitize_guild_name(message.guild.name) if message.guild else None mem_response = "\n".join(responses) if responses else clean_reply if is_monitoring: mem_response = "" mem_response = re.sub(r'<[^>]+?>', '', mem_response).strip() await add_interaction(user_id, message.channel.id, message.content, mem_response, guild_id) # 5. Monitoring silence if is_monitoring and not action_executed: return # 6. Send final reply if not action_executed: await self._send_final_reply(message, clean_reply, responses, json_str, accumulated_reply, is_monitoring) async def _send_final_reply(self, message, clean_reply, responses, json_str, accumulated_reply, is_monitoring): final_output = "\n".join(responses) if responses else clean_reply final_output = re.sub(r'<[^>]+?>', '', final_output).strip() if final_output: await self.messaging.reply_with_limit(message, format_mentions_in_text(final_output)) return if not is_monitoring: if json_str: try: data = json.loads(json_str) actions = data if isinstance(data, list) else [data] for act in actions: resp = act.get('response', '') if resp: await self.messaging.reply_with_limit(message, format_mentions_in_text(resp)) return except (json.JSONDecodeError, TypeError): pass await self.messaging.reply_with_limit(message, "Hmm, quelque chose s'est mal pass\u00e9. Tu peux reformuler ?") async def _handle_beta_signals(self, action_data, message): insight = action_data.get('insight') alert = action_data.get('alert') importance = action_data.get('importance', 0) 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 } if alert and self.admin_ids: admin_trigger_text = ( f"{alert}\n\n" f"**CONTEXTE D\u00c9TECTION**\n" f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n" f"- **Salon** : {context_data['channel_mention']}\n" + (f"- **Lien direct** : [Acc\u00e9der au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale") ) 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 (Priorit\u00e9 {importance}/10)", admin_trigger_text, color=discord.Color.red()) if insight and self.asset_ids: 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: 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_executor.execute(action, item, message) action_logger.log_action(action, str(message.author), str(message.guild), item, success, result) if not is_monitoring: if success: if result: await self.messaging.reply_with_limit(message, f"\u25c8 SUCCESS: {result}") elif action in ('JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER'): confirmations = { 'JOIN_VOICE': "Je rejoins le salon vocal.", 'LEAVE_VOICE': "Je quitte le salon vocal.", 'FORGET_USER': "Vos donn\u00e9es ont \u00e9t\u00e9 supprim\u00e9es." } await self.messaging.reply_with_limit(message, f"\u25c8 {confirmations.get(action, 'Action ex\u00e9cut\u00e9e.')}") else: await self.messaging.reply_with_limit(message, f"\u25c8 ERREUR: {result or 'Une erreur est survenue.'}") 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 # ==================== Shutdown ==================== async def close(self): 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() # Backward compatibility alias Superviseur = Bot