diff --git a/.env b/.env index c67028fe..931452e6 100644 --- a/.env +++ b/.env @@ -3,13 +3,13 @@ GUILD_ID=1370716551485067304 # --- CONFIGURATION BÊTA --- # IDs des Administrateurs (Séparés par des virgules) -ADMIN_IDS=971446412690722826 +ADMIN_IDS=971446412690722826,1324439906046574693 # IDs du Staff (Assets Primaires), séparés par des virgules PRIMARY_ASSETS_IDS=971446412690722826 # Channel d'Introspection (Log Stream de Bêta) INTROSPECTION_CHANNEL_ID=1461107327381016629 -# Modèles Ollama (Optimisation Radeon 8060S 64GB) -OLLAMA_MODEL_HEAVY=gpt-oss:120b -OLLAMA_MODEL_FAST=deepseek-r1:7b \ No newline at end of file +# Modèle Ollama (local, pas de clé API nécessaire) +OLLAMA_MODEL=gpt-oss:20b +OLLAMA_BASE_URL=http://localhost:11434 diff --git a/README.md b/README.md new file mode 100644 index 00000000..687fb5ca --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# Superviseur Bot (Bêta) + +Bot Discord IA d'assistance et de modération avancée, utilisant **Ollama** pour l'inférence LLM locale. + +## Fonctionnalités + +- **IA conversationnelle** : Répond aux questions et exécute des actions sur Discord via JSON +- **Modération silencieuse** : Scan de toxicité de chaque message via LLM en arrière-plan +- **Gestion vocale** : Transcription Whisper en temps réel + détection de mots-clés +- **Mémoire utilisateur** : Historique par utilisateur avec résumé automatique +- **Dispatch d'insights** : Coordination staff avec boutons d'acceptation Discord +- **RGPD** : Purge automatique des données > 30 jours + +## Installation + +### Prérequis + +- Python 3.9+ +- **Ollama** installé et en cours d'exécution (https://ollama.com) +- Modèle `gpt-oss:20b` disponible dans Ollama + +### Dépendances + +```bash +pip install -r requirements.txt +``` + +### Configuration Ollama + +```bash +# Pull le modèle dans Ollama +ollama pull gpt-oss:20b +``` + +### Configuration + +Créer un fichier `.env` à la racine du dossier `beta/` : + +```bash +# Discord +DISCORD_TOKEN=your_discord_bot_token +GUILD_ID=your_guild_id + +# LLM +OLLAMA_MODEL=gpt-oss:20b + +# Staff +ADMIN_IDS=123456789,987654321 +PRIMARY_ASSETS_IDS=111222333,444555666 + +# Channel d'introspection +INTROSPECTION_CHANNEL_ID=123456789 +``` + +## Utilisation + +```bash +cd beta +python main.py +``` + +Le bot va : +1. Se connecter à Ollama +2. Initialiser tous les composants (mémoire, modération, vocal) +3. Se connecter à Discord +4. Traiter les messages + +## Architecture + +``` +beta/ +├── main.py # Point d'entrée +├── core/ +│ ├── bot.py # Classe principale Superviseur +│ ├── llm.py # Gestionnaire LLM (Ollama API) +│ ├── action_router.py # Routage des actions IA → Discord +│ ├── dispatcher.py # Dispatch d'insights vers staff +│ ├── voice.py # Gestion vocale + Whisper +│ ├── tasks.py # Tâches de fond +│ ├── messaging.py # Envoi de messages +│ └── permissions.py # Whitelist et permissions +├── brain/ +│ ├── memoire.py # Mémoire utilisateur (JSON/Redis) +│ ├── moderation.py # Modération IA + SQLite +│ └── infos_serveurs.py # Infos serveur +├── commandes/ +│ ├── security/ # kick, ban, mute, warn, purge... +│ ├── salons/ # Créer, supprimer, modifier salons +│ ├── roles/ # Créer, supprimer, modifier rôles +│ └── autres/ # Config, status, ping, etc. +├── data/ # Logs, DB SQLite +└── memoires/ # Historique utilisateur (JSON) +``` + +## Actions IA + +Le bot peut exécuter les actions suivantes via LLM : +- `CREATE_CHANNEL`, `DELETE_CHANNEL`, `MODIFY_CHANNEL` +- `CREATE_ROLE`, `DELETE_ROLE`, `ADD_ROLE_TO_USER` +- `KICK`, `BAN`, `UNBAN`, `MUTE`, `TIMEOUT`, `WARN`, `PURGE` +- `JOIN_VOICE`, `LEAVE_VOICE` +- `ALERT`, `INSIGHT`, `FORGET_USER`, `READ_LOGS` + +## Support + +1. Vérifier les logs dans `data/bot.log` +2. Activer le mode debug : `LOG_LEVEL=DEBUG` +3. Vérifier qu'Ollama est actif : `ollama list` +python test_llama_integration.py +``` + +This will test: +- Model loading +- LLMManager functionality +- Text generation +- JSON extraction +- Performance + +## Performance + +### Expected Performance + +With the 120B model and optimized parameters: +- **Context**: 4096 tokens +- **Threads**: 16 CPU threads +- **Memory**: ~64GB RAM usage +- **Response time**: 5-15 seconds for typical queries +- **Concurrency**: 4 simultaneous requests + +### Performance Tips + +1. **Use mlock**: Prevents swapping to disk +2. **Optimize threads**: Match your CPU core count +3. **Monitor memory**: Ensure sufficient RAM +4. **Batch size**: Adjust based on your system + +### Monitoring + +The bot includes built-in metrics: +- LLM request counts +- Success/failure rates +- Response times +- Memory usage + +Enable metrics server with `METRICS_ENABLED=1`. + +## Troubleshooting + +### Common Issues + +1. **Model not found** + - Check `MODEL_PATH` in `.env` + - Verify model file exists + - Check file permissions + +2. **Memory errors** + - Ensure sufficient RAM + - Reduce `n_ctx` or `n_threads` + - Disable `use_mlock` for testing + +3. **Slow responses** + - Check CPU usage + - Verify model is in RAM + - Reduce `n_threads` if CPU is overloaded + +4. **Import errors** + - Install `llama-cpp-python` with proper backend + - Check Python version compatibility + +### Debug Mode + +Enable verbose logging by setting: +```bash +LOG_LEVEL=DEBUG +``` + +### Model Loading Issues + +If model loading fails: +1. Check model file integrity +2. Verify GGUF format compatibility +3. Try with `use_mlock=False` +4. Reduce model size for testing + +## Migration Notes + +### From Ollama + +If you were previously using Ollama: + +1. **Remove Ollama**: No longer needed +2. **Update configuration**: Remove Ollama URLs +3. **Install llama-cpp-python**: Add to requirements +4. **Test thoroughly**: Verify all functionality + +### Code Changes + +Key files modified: +- `beta.py`: Main entry point with model loading +- `superviseur/llm.py`: LLMManager with llama-cpp-python +- `superviseur/bot.py`: Bot integration +- `requirements.txt`: Updated dependencies + +## Security + +### Data Privacy + +- No external network calls +- All processing local +- GDPR compliance maintained +- Memory management for sensitive data + +### Model Security + +- Local model storage +- No external dependencies +- Controlled access +- Proper cleanup + +## Support + +For issues or questions: +1. Check the troubleshooting section +2. Run the test script +3. Enable debug logging +4. Check system resources +5. Verify model compatibility + +## Contributing + +When contributing to this project: +1. Test with the test script +2. Verify performance +3. Update documentation +4. Follow existing code patterns +5. Ensure backward compatibility + +## License + +This project is licensed under the same license as the original Superviseur bot. \ No newline at end of file diff --git a/commandes/autres/logger.py b/commandes/autres/logger.py index 72f05784..71a9c053 100644 --- a/commandes/autres/logger.py +++ b/commandes/autres/logger.py @@ -4,7 +4,7 @@ from datetime import datetime from .config import config class ActionLogger: - def __init__(self, log_file: str = 'actions.log'): + def __init__(self, log_file: str = 'data/actions.log'): self.log_file = log_file self.logger = logging.getLogger('ActionLogger') self.logger.setLevel(logging.INFO) diff --git a/commandes/categories/supprimer.py b/commandes/categories/supprimer.py index fd07f67f..a61e41bb 100644 --- a/commandes/categories/supprimer.py +++ b/commandes/categories/supprimer.py @@ -1,15 +1,52 @@ import discord +import re + +def strip_emoji(text): + """Remove emoji characters for fuzzy matching.""" + emoji_pattern = re.compile("[" + u"\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF" + u"\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U000024C2-\U0001F251" + u"\U0000FE00-\U0000FE0F\U0000200D\U0000E0100-\U0000E01EFF" + u"\u20E3\u00A9\u00AE\u2122" + "]+", flags=re.UNICODE) + return emoji_pattern.sub('', text).strip() + +async def find_category(guild, category_name_or_id): + """Find a category by ID or name (exact then fuzzy).""" + # 1. Try by ID + try: + cat_id = int(category_name_or_id) + cat = guild.get_channel(cat_id) + if cat and isinstance(cat, discord.CategoryChannel): + return cat + except (ValueError, TypeError): + pass + + # 2. Exact name + for cat in guild.categories: + if cat.name.casefold() == category_name_or_id.casefold(): + return cat + + # 3. Fuzzy (strip emoji) + clean_search = strip_emoji(category_name_or_id).lower().strip() + for cat in guild.categories: + clean_name = strip_emoji(cat.name).lower().strip() + if clean_name == clean_search or (clean_search and clean_search in clean_name): + return cat + + return None async def execute(bot, params, message): categories = params.get('categories', []) if not categories: - # Fallback for single category categories = [params] guild = message.guild deleted_count = 0 for category_params in categories: - category_name = category_params.get('category_name') - category = discord.utils.find(lambda cat: cat.name.casefold() == category_name.casefold(), guild.categories) + category_name = category_params.get('category_name') or category_params.get('category_id') + if not category_name: + continue + category = await find_category(guild, category_name) if not category: await message.channel.send(f"Catégorie '{category_name}' introuvable.") continue @@ -19,4 +56,4 @@ async def execute(bot, params, message): except discord.Forbidden: await message.channel.send(f"Je n'ai pas les permissions pour supprimer la catégorie '{category_name}'.") if deleted_count > 0: - await message.channel.send(f"{deleted_count} catégorie(s) supprimée(s).") + await message.channel.send(f"{deleted_count} catégorie(s) supprimée(s).") \ No newline at end of file diff --git a/commandes/salons/supprimer.py b/commandes/salons/supprimer.py index 37ee9eb6..64028b74 100644 --- a/commandes/salons/supprimer.py +++ b/commandes/salons/supprimer.py @@ -1,15 +1,66 @@ import discord +import re + +def strip_emoji(text): + """Remove emoji characters from channel/role names for fuzzy matching.""" + emoji_pattern = re.compile("[" + u"\U0001F600-\U0001F64F" # emoticons + u"\U0001F300-\U0001F5FF" # symbols & pictographs + u"\U0001F680-\U0001F6FF" # transport & map symbols + u"\U0001F1E0-\U0001F1FF" # flags (iOS) + u"\U00002702-\U000027B0" # dingbats + u"\U000024C2-\U0001F251" # misc + u"\U0000FE00-\U0000FE0F" # variation selectors + u"\U0000200D" # zero width joiner + u"\U0000E0100-\U0000E01EFF" # variation selectors supplement + u"\u20E3" # combining enclosing keycap + u"\u00A9\u00AE\u2122" # copyright, registered, trademark + "]+", flags=re.UNICODE) + return emoji_pattern.sub('', text).strip() + +async def find_channel(guild, channel_name_or_id): + """Find a channel by ID first, then by name (exact, then flexible).""" + # 1. Try by ID + try: + ch_id = int(channel_name_or_id) + channel = guild.get_channel(ch_id) + if channel: + return channel + except (ValueError, TypeError): + pass + + # 2. Try exact name match (case-insensitive) + for ch in guild.channels: + if ch.name.casefold() == channel_name_or_id.casefold(): + return ch + + # 3. Try fuzzy match (strip emojis from both sides) + clean_search = strip_emoji(channel_name_or_id).lower().strip() + for ch in guild.channels: + clean_name = strip_emoji(ch.name).lower().strip() + if clean_name == clean_search: + return ch + # 4. If the search name is contained in the channel name + if clean_search and clean_search in clean_name: + return ch + # 5. Or the channel name is contained in the search + if clean_search and clean_name in clean_search: + return ch + + return None async def execute(bot, params, message): channels = params.get('channels', []) if not channels: - # Fallback for single channel channels = [params] guild = message.guild deleted_count = 0 for channel_params in channels: - channel_name = channel_params.get('channel_name') - channel = discord.utils.find(lambda c: c.name.casefold() == channel_name.casefold(), guild.channels) + # Support both channel_id and channel_name + channel_name = channel_params.get('channel_name') or channel_params.get('channel_id') + if not channel_name: + continue # skip if no name or id + channel = await find_channel(guild, channel_name) if not channel: await message.channel.send(f"Salon '{channel_name}' introuvable.") continue diff --git a/commandes/security/auto_moderation.py b/commandes/security/auto_moderation.py index 659b97dd..c4d9ec05 100644 --- a/commandes/security/auto_moderation.py +++ b/commandes/security/auto_moderation.py @@ -9,7 +9,7 @@ import json import os from datetime import datetime -WARNINGS_FILE = 'warnings.json' +WARNINGS_FILE = 'data/warnings.json' def load_warnings(): """Charge les avertissements depuis le fichier JSON""" diff --git a/commandes/security/list_warnings.py b/commandes/security/list_warnings.py index 1d996425..6479ad03 100644 --- a/commandes/security/list_warnings.py +++ b/commandes/security/list_warnings.py @@ -2,7 +2,7 @@ import discord import json import os -WARNINGS_FILE = 'warnings.json' +WARNINGS_FILE = 'data/warnings.json' def load_warnings(): if os.path.exists(WARNINGS_FILE): diff --git a/commandes/security/warn.py b/commandes/security/warn.py index 5015310a..f067daa9 100644 --- a/commandes/security/warn.py +++ b/commandes/security/warn.py @@ -4,7 +4,7 @@ import os import aiohttp from datetime import datetime -WARNINGS_FILE = 'warnings.json' +WARNINGS_FILE = 'data/warnings.json' def load_warnings(): if os.path.exists(WARNINGS_FILE): @@ -123,7 +123,9 @@ async def execute(bot, params, message): target_mention = warn_params.get('target_user_mention') original_reason = warn_params.get('reason', 'Aucune raison spécifiée') # Rephrase the reason using Ollama - rephrased_reason = await rephrase_reason(original_reason, bot.ollama_api_url, bot.ollama_api_key, bot.ollama_model, bot.ollama_timeout, bot.ollama_temperature) + ollama_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/generate" + ollama_model = os.environ.get("OLLAMA_MODEL", "gpt-oss:20b") + rephrased_reason = await rephrase_reason(original_reason, ollama_url, "", ollama_model, 30, 0.7) reason = rephrased_reason if rephrased_reason else original_reason # Parse mention or ID if target_mention.startswith('<@') and target_mention.endswith('>'): @@ -168,7 +170,7 @@ async def execute(bot, params, message): ) await message.channel.send(embed=embed) # Generate full DM message using AI with original reason for polite reformulation - dm_message = await generate_dm_message(original_reason, warning_count, bot.ollama_api_url, bot.ollama_api_key, bot.ollama_model, bot.ollama_timeout, bot.ollama_temperature) + dm_message = await generate_dm_message(original_reason, warning_count, ollama_url, "", ollama_model, 30, 0.7) dm_content = dm_message if dm_message else f"Avertissement : {reason}. Nombre total : {warning_count}." # Send DM to user try: diff --git a/core/action_router.py b/core/action_router.py new file mode 100644 index 00000000..8da545a8 --- /dev/null +++ b/core/action_router.py @@ -0,0 +1,158 @@ +import discord +import logging +from typing import Dict + +logger = logging.getLogger('Superviseur') + +class ActionRouter: + """Routs generated AI semantic actions to the physical Discord moderative or administrative commands.""" + + def __init__(self, bot): + self.bot = bot + self._module_cache = {} + + async def execute_action(self, action: str, params: Dict, message: discord.Message) -> (bool, str): + """Execute an action from LLM response.""" + + # Normalize action name: replace spaces with underscores, uppercase + action = action.strip().upper().replace(' ', '_') + + # Merge nested params if LLM created a sub-object by mistake + for k in ['parametre', 'parametres', 'params']: + if k in params and isinstance(params[k], dict): + params.update(params[k]) + + # --- INSIGHT HOOK (LLM generated task for Staff) --- + if action == 'INSIGHT': + content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu" + + if 'conflit' in content.lower() or 'tension' in content.lower(): + logger.debug("Insight (Tension) logged.") + + 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": message.content[:200] + } + + await self.bot.dispatcher.dispatch_insight( + insight=content, + importance=params.get('importance', 2), + guild=message.guild, + context_data=context_data + ) + return True, None + # ----------------------------- + + # --- ALERT HOOK (LLM generated alert for Admin) --- + if action == 'ALERT': + content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide" + + embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red()) + embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}") + + for admin_id in self.bot.admin_ids: + u = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id) + if u: await u.send(embed=embed) + + return True, None + # ----------------------------- + + # --- VOICE ACTIONS --- + if action == 'JOIN_VOICE': + voice_state = getattr(message.author, 'voice', None) + + if not voice_state and self.bot.guild_id: + guild = self.bot.get_guild(self.bot.guild_id) + if guild: + member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id) + if member: + voice_state = member.voice + + if voice_state and voice_state.channel: + await self.bot.voice_manager.join_channel(voice_state.channel) + return True, f"Connecté à {voice_state.channel.name}" + else: + return False, "Vous n'êtes pas dans un salon vocal identifiable par le système." + + if action == 'LEAVE_VOICE': + await self.bot.voice_manager.leave_current() + return True, None + + # --- PRIVACY ACTIONS --- + if action == 'FORGET_USER': + from brain.memoire import delete_user_memory + delete_user_memory(message.author.id) + return True, None + + # --- PERMISSION ENFORCEMENT (ADMIN ONLY) --- + # Publicly accessible actions + PUBLIC_ACTIONS = {'PING', 'HELP', 'STATUS', 'JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER'} + + if action not in PUBLIC_ACTIONS: + is_admin_id = message.author.id in self.bot.admin_ids + has_admin_perms = message.author.guild_permissions.administrator if hasattr(message.author, 'guild_permissions') else False + + if not (is_admin_id or has_admin_perms): + logger.warning(f"◈ PERMISSION DENIED: {message.author.display_name} a tenté l'action '{action}'") + return False, "Cette opération est réservée aux administrateurs." + + action_map = { + 'KICK': 'commandes.security.kick', + 'BAN': 'commandes.security.ban', + 'UNBAN': 'commandes.security.unban', + 'UNMUTE': 'commandes.security.unmute', + 'MUTE': 'commandes.security.mute', + 'TIMEOUT': 'commandes.security.timeout', + 'PURGE': 'commandes.security.purge', + 'WARN': 'commandes.security.warn', + 'LIST_WARNINGS': 'commandes.security.list_warnings', + 'CREATE_CHANNEL': 'commandes.salons.creer', + 'DELETE_CHANNEL': 'commandes.salons.supprimer', + 'MODIFY_CHANNEL': 'commandes.salons.modifier', + 'RENAME_CHANNEL': 'commandes.salons.renommer', + 'MOVE_CHANNEL': 'commandes.salons.deplacer', + 'CREATE_ROLE': 'commandes.roles.creer', + 'DELETE_ROLE': 'commandes.roles.supprimer', + 'MODIFY_ROLE': 'commandes.roles.modifier', + 'RENAME_ROLE': 'commandes.roles.renommer', + 'MOVE_ROLE': 'commandes.roles.deplacer', + 'ADD_ROLE_TO_USER': 'commandes.roles.add_role', + 'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role', + 'CREATE_CATEGORY': 'commandes.categories.creer', + 'DELETE_CATEGORY': 'commandes.categories.supprimer', + 'MODIFY_CATEGORY': 'commandes.categories.modifier', + 'RENAME_CATEGORY': 'commandes.categories.renommer', + 'MOVE_CATEGORY': 'commandes.categories.deplacer', + 'PING': 'commandes.autres.ping', + 'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel', + 'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel', + 'SET_MUTE_ROLE': 'commandes.autres.setmuterole', + 'SEND_MESSAGE': 'commandes.autres.send_message', + 'HELP': 'commandes.autres.help', + 'STATUS': 'commandes.autres.status', + } + + module_name = action_map.get(action) + if module_name: + logger.debug(f"Importing module: {module_name}") + try: + module = self._module_cache.get(module_name) + if module is None: + module = __import__(module_name, fromlist=['execute']) + self._module_cache[module_name] = module + + logger.debug(f"Module {module_name} imported successfully") + await module.execute(self.bot, params, message) + logger.info(f"Action '{action}' executed successfully") + return True, None + + except Exception as e: + error_msg = str(e) + logger.error(f"Error during action execution '{action}': {error_msg}") + return False, error_msg + else: + error_msg = f"Unknown action: {action}" + logger.error(error_msg) + return False, error_msg diff --git a/core/bot.py b/core/bot.py new file mode 100644 index 00000000..b0c553fa --- /dev/null +++ b/core/bot.py @@ -0,0 +1,914 @@ +"""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 .dispatcher import AssetDispatcher +from .tasks import TaskManager +from .action_router import ActionRouter +import datetime +import pytz + +from brain.memoire import add_interaction, build_context_for_model, flush_all, MEMORY_DIR +from brain.moderation import init_db, log_infraction, scan_message_toxicity +from commandes.autres.config import config +from commandes.autres.logger import action_logger + +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 + # command_prefix is set by the constructor argument (e.g. "!") + + # Voice Management + self.voice_manager = VoiceManager(self) + + # LLM + self.llm = llama_model + + # Dispatcher & Sub-managers + self.dispatcher = AssetDispatcher(self) + self.task_manager = TaskManager(self) + self.action_router = ActionRouter(self) + + # Init SQLite Moderation DB + init_db() + + # 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 + cache invalidation.""" + 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): + """Handle member leave + cache invalidation.""" + 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é {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.""" + if message.author == self.user: + return + + logger.debug(f"Message received from {message.author} in {message.channel}") + + # 1. Silent Moderation Scan (Background) + # On ne bloque pas le bot pour l'analyse, mais on la lance pour chaque message + asyncio.create_task(self._handle_silent_scan(message)) + + # 2. DM Treatment + if isinstance(message.channel, discord.DMChannel): + processed = await self.dispatcher.handle_dm_response(message) + if processed: return + await self._handle_ai_interaction(message) + return + + # 3. Mention/Ping Check + if not self._should_respond(message): + return + + logger.info(f"◈ SYSTEM: Interaction directe détectée pour '{message.author.display_name}'") + # Direct interaction (AI Response) + 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_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 ==================== + + # ==================== Moderation Silencieuse ==================== + + async def _handle_silent_scan(self, message): + """Analyze message toxicity in the background and log to SQLite.""" + if not message.content or len(message.content.strip()) < 3: + return + + try: + # On ne scanne que si un modèle est chargé + 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"◈ MODERATION: Infraction détectée ({score}/1.0) pour {message.author.display_name}: {reason}") + + # Enregistrement en DB + 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: + """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 + + + 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" + # Limite stricte de l'historique pour éviter la saturation CPU + history = await build_context_for_model(user_id, max_recent=8, 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})" + # We include server_context and channels_list so the AI can answer questions about the server + prompt = f"{permissions_info}\n{server_context}\n{channels_list}\n{user_name_info}\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, prevent JSON leakage, and scrub technical tags.""" + # Append closing brace if missing (safety) + 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. Nettoyer la réponse textuelle (texte hors-JSON) + clean_reply = accumulated_reply + if json_str: + # On retire le bloc JSON identifié + clean_reply = clean_reply.replace(json_str, "").strip() + + # 2. Nettoyage Universel : Suppression des balises hallucinées (<|channel|>, thought, etc.) + # On supprime tout ce qui est entre < > (tags techniques) + clean_reply = re.sub(r'<[^>]+?>', '', clean_reply) + + # 3. Nettoyage des résidus de formatage + clean_reply = clean_reply.replace('```json', '').replace('```', '').strip() + + # 4. Suppression des clés orphelines si le modèle a foiré le JSON + clean_reply = re.sub(r'[“"”\'‘](thought|action|response)[“"”\'’]\s*:\s*[“"”\'‘].*?[“"”\'’],?', '', clean_reply, flags=re.DOTALL | re.IGNORECASE) + + # 5. Retrait ultime des accolades + if clean_reply.strip().startswith('{') or clean_reply.strip().endswith('}'): + clean_reply = clean_reply.strip('{}').strip() + + clean_reply = clean_reply.strip() + + is_explicit_none = False + responses = [] + full_response_for_memory = "" + + if json_str: + try: + # Normalisation JSON (guillemets) + json_str_norm = json_str.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'") + data = json.loads(json_str_norm) + actions_list = data if isinstance(data, list) else [data] + + for action_data in actions_list: + # Signaux BETA (Alert/Insight) + await self._handle_beta_signals(action_data, message) + + # Normalisation des accès aux clés + norm_data = {k.lower(): v for k, v in action_data.items()} + + if norm_data.get('action', '').upper() == 'NONE' and not norm_data.get('response'): + is_explicit_none = True + + if norm_data.get('response'): + # Scrub tags from the specific response field too + resp_text = re.sub(r'<[^>]+?>', '', str(norm_data.get('response'))).strip() + if resp_text: + responses.append(resp_text) + + # Exécution des actions système + res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring) + if res: action_executed = True + except Exception as e: + logger.debug(f"JSON parsing/processing error: {e}") + + # Reconstruction de la mémoire de conversation + final_responses_text = "\n".join(responses) + if final_responses_text: + full_response_for_memory = final_responses_text + elif clean_reply: + full_response_for_memory = clean_reply + + # Store interaction + user_id = str(message.author.id) + guild_id = sanitize_guild_name(message.guild.name) if message.guild else None + mem_response = full_response_for_memory if not is_monitoring else "" + # Nettoyage final des tags techniques pour la mémoire + mem_response = re.sub(r'<[^>]+?>', '', mem_response).strip() + await add_interaction(user_id, message.channel.id, message.content, mem_response, guild_id) + + # En mode monitoring, silence sauf demande explicite + if is_monitoring and not action_executed: + return + + # Envoi de la réponse finale si pas déjà fait par une action + if not action_executed: + final_output = final_responses_text if responses else clean_reply + # Nettoyage final avant envoi + final_output = re.sub(r'<[^>]+?>', '', final_output).strip() + + if final_output: + logger.debug(f"◈ Sending response: {final_output[:100]}...") + await self.messaging.reply_with_limit(message, format_mentions_in_text(final_output)) + elif not is_monitoring: + # Si on a un JSON avec une response, l'extraire + 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: + pass + logger.debug(f"◈ Raw response was: {accumulated_reply[:200]}...") + fallback_msg = "Hmm, quelque chose s'est mal passé dans ma réponse. Tu peux 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 brain.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() + diff --git a/core/context.py b/core/context.py new file mode 100644 index 00000000..159ad8fd --- /dev/null +++ b/core/context.py @@ -0,0 +1,93 @@ +"""Context building utilities for Superviseur bot.""" + +import discord +from typing import Tuple + + +def build_server_context(guild: discord.Guild) -> str: + """Build a comprehensive context string for the server.""" + if not guild: + return "\nContexte : Aucun serveur (Message Direct)." + + context = f"\nInformations sur le serveur '{guild.name}' (ID: {guild.id}):\n" + context += f"- Membres: {guild.member_count}\n" + context += f"- Propriétaire: {guild.owner.display_name if guild.owner else 'Inconnu'}\n" + context += f"- Créé le: {guild.created_at.strftime('%Y-%m-%d %H:%M:%S')} UTC\n" + + # Channels + text_channels = [ch.name for ch in guild.channels if isinstance(ch, discord.TextChannel)] + voice_channels = [ch.name for ch in guild.channels if isinstance(ch, discord.VoiceChannel)] + categories = [cat.name for cat in guild.channels if isinstance(cat, discord.CategoryChannel)] + + if text_channels: + context += f"- Salons textes: {len(text_channels)} ({', '.join(text_channels[:5])}...)\n" + else: + context += "- Salons textes: 0\n" + + context += f"- Salons vocaux: {len(voice_channels)}\n" + + if categories: + context += f"- Catégories: {len(categories)} ({', '.join(categories[:3])}...)\n" + else: + context += "- Catégories: 0\n" + + # Roles (except @everyone) + roles = [role.name for role in guild.roles[1:]] # Skip @everyone + if roles: + context += f"- Rôles: {len(roles)}\n" + for role_name in roles: + context += f" - {role_name}\n" + else: + context += "- Rôles: 0\n" + + # Liste des membres (si le serveur n'est pas trop grand) + if len(guild.members) <= 200: + members_list = [m.display_name for m in guild.members] + context += f"- Tous les membres ({len(members_list)}): {', '.join(members_list)}\n" + else: + recent_members = sorted(guild.members, key=lambda m: m.joined_at or m.created_at, reverse=True)[:5] + context += f"- Membres récents: {', '.join([m.display_name for m in recent_members])}\n" + + return context + + +def build_channels_list(guild: discord.Guild) -> str: + """Build a formatted list of all channels with IDs.""" + if not guild: + return "" + + lines = ["\nListe des salons (nom | id | type):"] + for ch in guild.channels: + if isinstance(ch, discord.TextChannel): + ch_type = 'textuel' + elif isinstance(ch, discord.VoiceChannel): + ch_type = 'vocal' + else: + ch_type = 'catégorie' + lines.append(f"- {ch.name} | {ch.id} | {ch_type}") + return "\n".join(lines) + + +def build_context_for_message( + guild: discord.Guild, + channels_list_cache: dict, + server_context_cache: dict +) -> Tuple[str, str]: + """Build channels list and server context with caching.""" + if not guild: + return "", build_server_context(None) + + guild_id = guild.id + + # Build channels list with caching + if guild_id not in channels_list_cache: + channels_list_cache[guild_id] = build_channels_list(guild) + channels_list = channels_list_cache[guild_id] + + # Build server context with caching + if guild_id not in server_context_cache: + server_context_cache[guild_id] = build_server_context(guild) + server_context = server_context_cache[guild_id] + + return channels_list, server_context + diff --git a/core/dispatcher.py b/core/dispatcher.py new file mode 100644 index 00000000..d3de86a5 --- /dev/null +++ b/core/dispatcher.py @@ -0,0 +1,234 @@ +import json +import os +import time +import datetime +import logging +from typing import Dict, List, Optional +import discord +from .permissions import check_is_admin + +logger = logging.getLogger('Superviseur') + +ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json") +TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json") + +class AssetDispatcher: + """Manages autonomous staff coordination and task assignments.""" + + def __init__(self, bot): + self.bot = bot + self.assets = self._load_data(ASSETS_DATA_PATH) + self.tasks = self._load_data(TASKS_DATA_PATH) + self.interview_states = {} # {user_id: bool} + + def _load_data(self, path): + if os.path.exists(path): + with open(path, 'r') as f: + return json.load(f) + return {} + + def _save_data(self, data, path): + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + async def start_interview(self, user_id: int): + """Initiate the autonomous interview protocol via DM.""" + if str(user_id) in self.assets: + return + + user = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id) + if not user: return + + self.interview_states[user_id] = True + prompt = ( + "◈ PROTOCOLE D'ENTRETIEN : ACQUISITION DE DISPONIBILITÉS ◈\n\n" + "Bonjour Asset Primaire. Pour optimiser la coordination du système, j'ai besoin de connaître vos disponibilités.\n" + "Veuillez m'indiquer vos horaires habituels (cours, travail, repos) ainsi que votre fuseau horaire.\n" + "Répondez-moi naturellement, je traiterai vos données." + ) + await self.bot.messaging.send_embed(user, "COMMUNICATION ENTRANTE", prompt, color=discord.Color.blue(), is_asset=True) + + async def handle_dm_response(self, message: discord.Message): + """Process natural language response from an Asset for their schedule.""" + u_id = str(message.author.id) + if u_id not in self.interview_states: return False + + # Indiquer que Bêta réfléchit + async with message.channel.typing(): + # Use LLM to extract schedule + prompt = ( + f"ANALYSE DE DISPONIBILITÉ ASSET\n" + f"Texte du membre : \"{message.content}\"\n\n" + f"FONCTION : Extraire les horaires au format JSON strict.\n" + f"FORMAT REQUIS :\n" + f"`{{\"schedule\": {{\"mon\": [[\"HH:MM\", \"HH:MM\"]], \"tue\": [], ...}}, \"timezone\": \"Europe/Paris\"}}`\n" + f"Note : Si l'Asset dit être libre tout le temps, laissez les jours vides []. S'il donne des heures, mettez-les.\n" + f"IMPORTANT: Ne répondez QUE par le bloc JSON." + ) + + try: + payload = self.bot.llm.build_payload( + prompt=prompt, + system_prompt="Tu es un expert en extraction de données. Réponds uniquement en JSON.", + force_json=True + ) + resp = await self.bot.llm.call_llama(payload) + json_str = self.bot.llm.extract_json_actions(resp) + + if not json_str and resp.strip().startswith('{'): + json_str = resp.strip() + + if json_str: + data = json.loads(json_str) + if isinstance(data, list): data = data[0] + + if "schedule" in data: + self.assets[u_id] = { + "name": message.author.display_name, + "schedule": data.get("schedule", {}), + "timezone": data.get("timezone", "Europe/Paris"), + "last_update": time.time() + } + self._save_data(self.assets, ASSETS_DATA_PATH) + + del self.interview_states[u_id] + await self.bot.messaging.send_embed( + message.author, + "INTÉGRATION RÉUSSIE", + "Vos paramètres de disponibilité ont été synchronisés avec succès. Je vous contacterai selon ces critères.", + color=discord.Color.green(), + is_asset=True + ) + return True + + # Si on arrive ici, l'extraction a échoué + logger.warning(f"Failed to extract schedule from: {resp}") + await self.bot.messaging.send_embed( + message.author, + "ÉCHEC D'ACQUISITION", + "Je n'ai pas pu parser vos horaires. Veuillez être plus précis (ex: 'Lundi de 8h à 12h') ou vérifier le format.", + color=discord.Color.orange(), + is_asset=True + ) + except Exception as e: + logger.error(f"Failed to process interview response: {e}") + await message.channel.send(f"◈ ERREUR CRITIQUE DURANT L'ANALYSE : {e}") + + return True + + def is_available(self, user_id: int, guild: discord.Guild) -> bool: + """Check if an asset is available and physically present on the server.""" + u_id = str(user_id) + + # 1. Server Presence & Permission Check (only if guild is provided) + if guild: + member = guild.get_member(user_id) + if not member: + return False + + # Verify they are ACTUALLY staff on this server + if not check_is_admin(member.guild_permissions): + # logger.warning(f"Asset {member.display_name} is present but lacks STAFF permissions on {guild.name}") + return False + + + # 2. Schedule Check + asset = self.assets.get(u_id) + if not asset: return True # Default available if unknown + + # Simple weekday/hour check (basic implementation) + now = datetime.datetime.now() # Should use asset's timezone in real impl + day = now.strftime("%a").lower()[:3] + current_time = now.strftime("%H:%M") + + schedule = asset.get("schedule", {}).get(day, []) + if not schedule: return True # Available if no specific "busy" blocks + + for start, end in schedule: + if start <= current_time <= end: + return False # Occupied + + return True + + async def dispatch_insight(self, insight: str, importance: int, guild: discord.Guild, context_data: dict): + """Dispatch an insight to the best available asset.""" + available_assets = [] + for asset_id in self.bot.asset_ids: + if self.is_available(asset_id, guild): + available_assets.append(asset_id) + + if not available_assets: + # Fallback to Admins if no asset available + 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, "DISPATCH FALLBACK: NO ASSETS AVAILABLE", insight, color=discord.Color.gold()) + return + + # Target selection (Simple round-robin or first available for now) + target_id = available_assets[0] + target_user = self.bot.get_user(target_id) or await self.bot.fetch_user(target_id) + if target_user: + # Create Task ID and Data + task_id = f"task_{int(time.time())}" + self.tasks[task_id] = { + "asset_id": target_id, + "content": insight, + "importance": importance, + "created_at": time.time(), + "deadline": time.time() + (60 * (11 - importance) * 5), + "guild_id": guild.id if guild else None, + "status": "PENDING" + } + self._save_data(self.tasks, TASKS_DATA_PATH) + + view = TaskAcceptView(task_id, self) + + # Création des champs d'information structurés + fields = [ + {"name": "👤 Sujet", "value": f"{context_data['author_mention']} ({context_data['author_name']})", "inline": True}, + {"name": "📍 Salon", "value": context_data['channel_mention'], "inline": True}, + {"name": "📝 Contenu", "value": context_data['content'], "inline": False} + ] + + # Ajout du lien direct s'il existe + if context_data.get('jump_url'): + fields.append({"name": "🔗 Lien Direct", "value": f"[Accéder au message]({context_data['jump_url']})", "inline": False}) + + desc = ( + f"**MISSION OPÉRATIONNELLE**\n" + f"{insight}\n\n" + f"*Veuillez confirmer la prise en charge pour verrouiller la tâche.*" + ) + + await self.bot.messaging.send_embed( + target_user, + f"ASSIGNATION TACTIQUE (Priorité {importance}/10)", + desc, + color=discord.Color.blue(), + is_asset=True, + fields=fields + ) + # Send view separately + await target_user.send(view=view) + +class TaskAcceptView(discord.ui.View): + def __init__(self, task_id, dispatcher): + super().__init__(timeout=None) + self.task_id = task_id + self.dispatcher = dispatcher + + @discord.ui.button(label="Accepter la tâche", style=discord.ButtonStyle.green) + async def accept(self, interaction: discord.Interaction, button: discord.ui.Button): + task = self.dispatcher.tasks.get(self.task_id) + if task: + task["status"] = "ACCEPTED" + task["accepted_at"] = time.time() + self.dispatcher._save_data(self.dispatcher.tasks, TASKS_DATA_PATH) + + button.disabled = True + button.label = "Acceptée ✅" + await interaction.response.edit_message(view=self) + await interaction.followup.send("◈ Tâche verrouillée. Bonne intervention.", ephemeral=True) + else: + await interaction.response.send_message("Tâche introuvable ou expirée.", ephemeral=True) diff --git a/core/llm.py b/core/llm.py new file mode 100644 index 00000000..a1fcddb6 --- /dev/null +++ b/core/llm.py @@ -0,0 +1,160 @@ +"""Ollama integration for Superviseur bot - replaces llama-cpp-python for better stability.""" + +import json +import logging +import asyncio +import aiohttp +import re +import os +from typing import Optional, Dict, Any, List + +logger = logging.getLogger('Superviseur') + +class LLMManager: + """Manages LLM interactions via Ollama API.""" + + def __init__( + self, + model_name: str = "gpt-oss:20b", + base_url: str = None, + temperature: float = 0.5 + ): + self.model_name = model_name + self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") + self.temperature = temperature + self._lock = asyncio.Lock() + + def build_payload( + self, + prompt: str, + system_prompt: Optional[str] = None, + vision_model: bool = False, + attachments: Optional[list] = None, + force_json: bool = True + ) -> Dict[str, Any]: + """Build the payload for Ollama Generate API.""" + return { + "model": self.model_name, + "prompt": prompt, + "system": system_prompt or "Tu es une IA serviable.", + "stream": True, # Streaming pour voir la réponse en direct dans le terminal + "format": "json" if force_json else "", + "options": { + "temperature": self.temperature, + "num_ctx": 16384, + "repeat_penalty": 1.2, + "num_predict": 4096, + "top_p": 0.9, + "top_k": 40 + } + } + + async def call_llama(self, payload: Dict[str, Any]) -> str: + """Call Ollama Generate API and stream response.""" + async with self._lock: + try: + # Automatic payload conversion for legacy compatibility (e.g. from moderation.py) + ollama_payload = { + "model": payload.get("model", self.model_name), + "prompt": payload["prompt"], + "system": payload.get("system", payload.get("system_prompt", "Tu es une IA serviable.")), + "stream": True, # Streaming pour voir la réponse en direct + "think": False, # Désactive le reasoning GPT-OSS + "format": "", + "options": { + "temperature": payload.get("temperature", self.temperature), + "num_ctx": payload.get("n_ctx", 16384), + "num_predict": payload.get("max_tokens") or payload.get("num_predict", 8192), + "repeat_penalty": payload.get("repeat_penalty") or payload.get("repeat_last_n", 1.2), + "stop": payload.get("stop", []) + } + } + + accumulated_reply = "" + api_url = f"{self.base_url}/api/generate" + + # Build headers with optional API key + headers = {} + api_key = os.environ.get("OLLAMA_API_KEY", "") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with aiohttp.ClientSession() as session: + async with session.post(api_url, json=ollama_payload, headers=headers) as response: + if response.status != 200: + err_text = await response.text() + logger.error(f"Ollama API Error ({response.status}): {err_text}") + return "" + + # Streaming en direct dans le terminal + async for line in response.content: + if line: + try: + chunk = json.loads(line) + resp = chunk.get("response", "") + if resp: + accumulated_reply += resp + print(resp, end="", flush=True) + if chunk.get("done"): + break + except json.JSONDecodeError: + continue + print() + return accumulated_reply.strip() + + except Exception as e: + logger.error(f"Error calling Ollama: {e}") + return "" + + async def close_session(self): + pass + + def extract_json_actions(self, text: str) -> Optional[str]: + """ + Extract JSON action objects from text with surgical precision. + Handles both single objects {...} and arrays [...]. + Also handles // comments that GPT-OSS sometimes inserts. + """ + if not text: + return None + + text = text.strip() + + # Remove // comments (GPT-OSS sometimes adds them) + text = re.sub(r'//.*?\n', '\n', text) + text = re.sub(r'//.*?$', '', text, flags=re.MULTILINE) + + # 1. Try to find a JSON array [...] + first_bracket = text.find('[') + if first_bracket != -1: + last_bracket = text.rfind(']') + if last_bracket > first_bracket: + array_str = text[first_bracket:last_bracket+1] + try: + json.loads(array_str) + return array_str + except json.JSONDecodeError: + pass + + # 2. Try to find a JSON object {...} + first_brace = text.find('{') + if first_brace == -1: + return None + + last_brace = text.rfind('}') + if last_brace == -1 or last_brace < first_brace: + return None + + stack = [] + for i in range(first_brace, len(text)): + if text[i] == '{': + stack.append('{') + elif text[i] == '}': + if stack: + stack.pop() + if not stack: + match = text[first_brace:i+1] + if '"action"' in match.lower(): + return match + + return None \ No newline at end of file diff --git a/core/voice.py b/core/voice.py new file mode 100644 index 00000000..c3e7cab9 --- /dev/null +++ b/core/voice.py @@ -0,0 +1,370 @@ +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 evaluate_and_connect(self, guild): + """Analyze voice channels in guild and connect if strategically relevant.""" + if self.current_channel and self.current_channel.guild == guild: + 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.""" + 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 = 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() + ) diff --git a/main.py b/main.py new file mode 100644 index 00000000..76750dd3 --- /dev/null +++ b/main.py @@ -0,0 +1,93 @@ +"""Main entry point for Superviseur bot - refactored for Ollama stability.""" + +import asyncio +import logging +import os +import sys +import discord +from dotenv import load_dotenv + +# Import our modular components +from core.bot import Superviseur +from core.llm import LLMManager + +# Setup Logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("data/bot.log"), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger('Superviseur') + +# Load environment +load_dotenv() + +TOKEN = os.getenv("DISCORD_TOKEN") +GUILD_ID = os.getenv("GUILD_ID") + +if not TOKEN: + logger.error("DISCORD_TOKEN n'est pas défini dans le fichier .env") + exit(1) + +if not GUILD_ID: + logger.error("GUILD_ID n'est pas défini ou invalide.") + exit(1) + +# Ollama model name (from .env) +MODEL_NAME = os.getenv("OLLAMA_MODEL", "gpt-oss:20b") + +async def main(): + """Main startup sequence.""" + logger.info("◈ SYSTEM: Initializing Superviseur with Ollama Backend...") + + # 1. Initialize LLM Manager (Connecting to Ollama service) + # The actual model path is managed by Ollama via Modelfile + llm = LLMManager(model_name=MODEL_NAME) + + # 2. Build the System Prompt + SYSTEM_PROMPT = ( + "Tu es Bêta, une IA d'assistance et de modération avancée. " + "Tu es serviable, amical et tu réponds avec précision à TOUTES les questions des utilisateurs. " + "Tu as la capacité d'exécuter des actions sur le serveur Discord si l'utilisateur te le demande.\n" + "Tu dois TOUJOURS utiliser ce format JSON pour tes réponses :\n" + "Pour une seule action : {\"thought\": \"analyse\", \"action\": \"ACTION\", \"response\": \"message\", \"PARAM\": \"valeur\"}\n" + "Pour PLUSIEURS actions (ex: créer un salon ET un rôle), retourne une LISTE JSON d'objets :\n" + "[{\"thought\": \"...\", \"action\": \"CREATE_CHANNEL\", \"channel_name\": \"test\"}, {\"action\": \"CREATE_ROLE\", \"role_name\": \"test\", \"response\": \"J'ai tout créé !\"}]\n" + "ACTIONS possibles : NONE, JOIN_VOICE, LEAVE_VOICE, FORGET_USER, READ_LOGS, ALERT, INSIGHT, CREATE_CHANNEL, DELETE_CHANNEL, RENAME_CHANNEL, MODIFY_CHANNEL, KICK, BAN, UNBAN, MUTE, UNMUTE, TIMEOUT, WARN, PURGE, CREATE_ROLE, DELETE_ROLE, ADD_ROLE_TO_USER, REMOVE_ROLE_FROM_USER, CREATE_CATEGORY, DELETE_CATEGORY.\n" + "Ajoute les paramètres d'action directement à la racine de l'objet JSON de l'action." + ) + + # 3. Initialize and Start the Bot + # Define required intents + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + intents.voice_states = True + + bot = Superviseur( + llama_model=llm, + command_prefix="!", + guild_id=int(GUILD_ID), + system_prompt=SYSTEM_PROMPT, + intents=intents + ) + + try: + await bot.start(TOKEN) + except Exception as e: + logger.critical(f"◈ SYSTEM: Fatal error during startup: {e}") + finally: + await bot.close() + +if __name__ == "__main__": + try: + # Use uvloop if available for better performance + import uvloop + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + except ImportError: + pass + + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt index 18e88530..397414b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,19 @@ -PyNaCl +# Core dependencies for Superviseur bot discord.py>=2.3.0 -aiohttp>=3.8.0 -aiofiles>=23.1.0 -uvloop>=0.17.0 ; python_version >= '3.8' -fastapi>=0.95.0 -uvicorn>=0.22.0 -prometheus_client>=0.16.0 -httpx>=0.24.0 -redis>=5.3.0 -pytest>=7.0.0 -flake8>=6.0.0 python-dotenv>=1.0.0 -psutil>=5.9.0 -faster-whisper -git+https://github.com/imayhaveborkedit/discord-ext-voice-recv -pytz \ No newline at end of file +aiohttp>=3.8.0 +aiofiles>=23.0.0 +pytz>=2023.3 +redis>=4.5.0 +pynacl>=1.5.0 +faster-whisper>=0.10.0 +uvloop>=0.17.0; sys_platform != "win32" + +# Optional dependencies for enhanced functionality +fastapi>=0.100.0 +uvicorn[standard]>=0.20.0 +prometheus-client>=0.15.0 + +# Development and testing +pytest>=7.0.0 +pytest-asyncio>=0.21.0 \ No newline at end of file diff --git a/superviseur/action_router.py b/superviseur/action_router.py new file mode 100644 index 00000000..b717910a --- /dev/null +++ b/superviseur/action_router.py @@ -0,0 +1,144 @@ +import discord +import logging +from typing import Dict + +logger = logging.getLogger('Superviseur') + +class ActionRouter: + """Routs generated AI semantic actions to the physical Discord moderative or administrative commands.""" + + def __init__(self, bot): + self.bot = bot + self._module_cache = {} + + async def execute_action(self, action: str, params: Dict, message: discord.Message) -> (bool, str): + """Execute an action from LLM response.""" + + # --- INSIGHT HOOK (LLM generated task for Staff) --- + if action == 'INSIGHT': + content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu" + + if 'conflit' in content.lower() or 'tension' in content.lower(): + logger.debug("Insight (Tension) logged.") + + 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": message.content[:200] + } + + await self.bot.dispatcher.dispatch_insight( + insight=content, + importance=params.get('importance', 2), + guild=message.guild, + context_data=context_data + ) + return True, None + # ----------------------------- + + # --- ALERT HOOK (LLM generated alert for Admin) --- + if action == 'ALERT': + content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide" + + embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red()) + embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}") + + for admin_id in self.bot.admin_ids: + u = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id) + if u: await u.send(embed=embed) + + return True, None + # ----------------------------- + + # --- VOICE ACTIONS --- + if action == 'JOIN_VOICE': + voice_state = getattr(message.author, 'voice', None) + + if not voice_state and self.bot.guild_id: + guild = self.bot.get_guild(self.bot.guild_id) + if guild: + member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id) + if member: + voice_state = member.voice + + if voice_state and voice_state.channel: + await self.bot.voice_manager.join_channel(voice_state.channel) + return True, f"Connecté à {voice_state.channel.name}" + else: + return False, "Vous n'êtes pas dans un salon vocal identifiable par le système." + + if action == 'LEAVE_VOICE': + await self.bot.voice_manager.leave_current() + return True, None + + # --- PRIVACY ACTIONS --- + if action == 'FORGET_USER': + from ia.memoire import delete_user_memory + delete_user_memory(message.author.id) + return True, None + + # Check whitelist + if self.bot.whitelist_manager.whitelist: + required_level = self.bot.whitelist_manager.get_required_level(action) + if required_level and not self.bot.has_whitelist_permission(message.author.id, required_level): + return False, f"Niveau whitelist requis: {required_level}" + + action_map = { + 'KICK': 'commandes.security.kick', + 'BAN': 'commandes.security.ban', + 'UNBAN': 'commandes.security.unban', + 'UNMUTE': 'commandes.security.unmute', + 'MUTE': 'commandes.security.mute', + 'TIMEOUT': 'commandes.security.timeout', + 'PURGE': 'commandes.security.purge', + 'WARN': 'commandes.security.warn', + 'LIST_WARNINGS': 'commandes.security.list_warnings', + 'CREATE_CHANNEL': 'commandes.salons.creer', + 'DELETE_CHANNEL': 'commandes.salons.supprimer', + 'MODIFY_CHANNEL': 'commandes.salons.modifier', + 'RENAME_CHANNEL': 'commandes.salons.renommer', + 'MOVE_CHANNEL': 'commandes.salons.deplacer', + 'CREATE_ROLE': 'commandes.roles.creer', + 'DELETE_ROLE': 'commandes.roles.supprimer', + 'MODIFY_ROLE': 'commandes.roles.modifier', + 'RENAME_ROLE': 'commandes.roles.renommer', + 'MOVE_ROLE': 'commandes.roles.deplacer', + 'ADD_ROLE_TO_USER': 'commandes.roles.add_role', + 'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role', + 'CREATE_CATEGORY': 'commandes.categories.creer', + 'DELETE_CATEGORY': 'commandes.categories.supprimer', + 'MODIFY_CATEGORY': 'commandes.categories.modifier', + 'RENAME_CATEGORY': 'commandes.categories.renommer', + 'MOVE_CATEGORY': 'commandes.categories.deplacer', + 'PING': 'commandes.autres.ping', + 'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel', + 'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel', + 'SET_MUTE_ROLE': 'commandes.autres.setmuterole', + 'SEND_MESSAGE': 'commandes.autres.send_message', + 'HELP': 'commandes.autres.help', + 'STATUS': 'commandes.autres.status', + } + + module_name = action_map.get(action) + if module_name: + logger.debug(f"Importing module: {module_name}") + try: + module = self._module_cache.get(module_name) + if module is None: + module = __import__(module_name, fromlist=['execute']) + self._module_cache[module_name] = module + + logger.debug(f"Module {module_name} imported successfully") + await module.execute(self.bot, params, message) + logger.info(f"Action '{action}' executed successfully") + return True, None + + except Exception as e: + error_msg = str(e) + logger.error(f"Error during action execution '{action}': {error_msg}") + return False, error_msg + else: + error_msg = f"Unknown action: {action}" + logger.error(error_msg) + return False, error_msg diff --git a/superviseur/bot.py b/superviseur/bot.py index a329be67..6fd1f26e 100644 --- a/superviseur/bot.py +++ b/superviseur/bot.py @@ -21,6 +21,9 @@ 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 @@ -58,29 +61,21 @@ class Superviseur(commands.Bot): def __init__( self, guild_id: int, - ollama_api_url: str, - ollama_model: str, - ollama_timeout: int, + llama_model, system_prompt: str, command_prefix: str, intents: discord.Intents, - ollama_temperature: float = 0.7, - ollama_api_key: Optional[str] = None, - ollama_moderation_model: Optional[str] = None, - ollama_model_fast: Optional[str] = None + temperature: float = 0.4, + model_loaded: bool = True ): super().__init__(command_prefix=command_prefix, intents=intents) # Configuration self.guild_id = guild_id - self.ollama_api_url = ollama_api_url - self.ollama_model = ollama_model - self.ollama_model_fast = ollama_model_fast or ollama_model - self.ollama_moderation_model = ollama_moderation_model or ollama_model - self.ollama_timeout = ollama_timeout + self.llama_model = llama_model self.system_prompt = system_prompt - self.ollama_temperature = ollama_temperature - self.ollama_api_key = ollama_api_key + self.temperature = temperature + self.model_loaded = model_loaded # Identity self.system_name = "Bêta" @@ -142,14 +137,15 @@ class Superviseur(commands.Bot): # LLM self.llm = LLMManager( - ollama_api_url=ollama_api_url, - ollama_model=ollama_model, - ollama_timeout=ollama_timeout, - ollama_temperature=ollama_temperature + llama_model=llama_model, + temperature=temperature ) - # Dispatcher + # 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")) @@ -217,156 +213,14 @@ class Superviseur(commands.Bot): os.makedirs(guild_dir, exist_ok=True) print(f'Memory directory created for guild {guild.name}: {guild_dir}') - # Setup HTTP session - await self.llm.get_session() + # 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.loop.create_task(self._introspection_report_loop()) - self.loop.create_task(self._voice_report_loop()) - self.loop.create_task(self._voice_report_loop()) - self.loop.create_task(self._daily_report_check_loop()) - self.loop.create_task(self._gdpr_purge_loop()) - - # Start interviews for missing assets - for asset_id in self.asset_ids: - if str(asset_id) not in self.dispatcher.assets: - self.loop.create_task(self.dispatcher.start_interview(asset_id)) + self.task_manager.start_all() - async def _voice_evaluation_loop(self): - """Periodic check for tactical voice connections.""" - await self.wait_until_ready() - while not self.is_closed(): - for guild in self.guilds: - await self.voice_manager.evaluate_and_connect(guild) - await asyncio.sleep(30) # Reduit à 30s pour plus de réactivité en tâche de fond - - async def _introspection_report_loop(self): - """Periodic broadcast of system health metrics.""" - await self.wait_until_ready() - while not self.is_closed(): - await asyncio.sleep(600) # Every 10 minutes - await self._send_health_report() - - async def _send_health_report(self): - """Build and send a clinical health report embed.""" - # Calculate success rate - total = self.metrics["total_llm_requests"] - success = self.metrics["total_llm_success"] - rate = (success / total * 100) if total > 0 else 100 - avg_time = (self.metrics["total_llm_time"] / success) if success > 0 else 0 - - status_report = ( - f"**◈ INTELLIGENCE CORE**\n" - f"- Requests Processed : `{total}`\n" - f"- Reliability Rate : `{rate:.1f}%`\n" - f"- Latency Heuristic : `{avg_time:.2f}s` (Target: < 5s)\n\n" - f"**◈ TACTICAL OVERVIEW**\n" - f"- Voice Protocol : `READY`\n" - f"- Active Deployment : `{self.voice_manager.current_channel.name if self.voice_manager.current_channel else 'NONE'}`\n" - ) - - await self.introspection_log( - "SYSTEM STATUS REPORT", - status_report, - discord.Color.dark_blue() - ) - - async def _daily_report_check_loop(self): - """Check for 21:30 and trigger the daily report using local timezone.""" - await self.wait_until_ready() - report_sent_today = False - while not self.is_closed(): - now = datetime.datetime.now(self.timezone) - if now.hour == 21 and now.minute == 30: - if not report_sent_today: - await self._send_daily_summary() - report_sent_today = True - else: - report_sent_today = False - await asyncio.sleep(60) - - async def _gdpr_purge_loop(self): - """Daily purge of old memory files (30 days) for GDPR compliance.""" - from ia.memoire import purge_old_memories - while not self.is_closed(): - # Run purge at 03:00 AM - now = datetime.datetime.now(self.timezone) - if now.hour == 3 and now.minute == 0: - logger.info("◈ RGPD: Démarrage de la purge quotidienne...") - deleted = await purge_old_memories(days=30) - logger.info(f"◈ RGPD: Purge terminée. {deleted} fichiers supprimés.") - await asyncio.sleep(65) # Avoid multiple triggers in the same minute - await asyncio.sleep(30) - - async def _send_daily_summary(self): - """Generate and send a dense, highly detailed clinical report at 21:30.""" - tasks = self.dispatcher.tasks - tense_sessions = self.metrics.get("daily_tense_sessions", 0) - - # 1. Statistiques par Asset - asset_stats = {} # {asset_id: {"name": str, "accepted": 0, "missed": 0}} - for t_id, t_data in tasks.items(): - a_id = t_data.get("asset_id") - if a_id not in asset_stats: - asset_name = "Inconnu" - if str(a_id) in self.dispatcher.assets: - asset_name = self.dispatcher.assets[str(a_id)].get("name", "Anonyme") - asset_stats[a_id] = {"name": asset_name, "accepted": 0, "missed": 0, "total": 0} - - asset_stats[a_id]["total"] += 1 - if t_data["status"] == "ACCEPTED": - asset_stats[a_id]["accepted"] += 1 - elif t_data["status"] == "PENDING" and t_data.get("deadline", 0) < time.time(): - asset_stats[a_id]["missed"] += 1 - - stats_lines = [] - for a_id, s in asset_stats.items(): - stats_lines.append(f"- **{s['name']}** : `{s['accepted']}/{s['total']}` acceptées, `{s['missed']}` manquées.") - - # 2. Liste détaillée des tâches - task_details = [] - for t_id, t_data in list(tasks.items())[-15:]: # On prend les 15 dernières - status_emoji = "✅" if t_data["status"] == "ACCEPTED" else "❌" if t_data.get("deadline", 0) < time.time() else "⏳" - asset_name = self.dispatcher.assets.get(str(t_data['asset_id']), {}).get('name', 'N/A') - task_details.append(f"{status_emoji} `{t_id}` | Staff: {asset_name} | Urgence: {t_data['importance']} | Context: {t_data['content'][:50]}...") - - summary = ( - f"**◈ RAPPORT ANALYTIQUE DE FIN DE CYCLE ◈**\n" - f"Période : Dernières 24h | Heure : 21h30\n\n" - f"**◈ BILAN GLOBAL DES OPÉRATIONS**\n" - f"- Volume total d'actions de modération : `{len(tasks)}` \n" - f"- Sessions vocales sous tension : `{tense_sessions}`\n\n" - f"**◈ PERFORMANCE DES ASSETS**\n" - + ("\n".join(stats_lines) if stats_lines else "Aucune activité de staff enregistrée.") + "\n\n" - f"**◈ LOG DÉTAILLÉ DES DERNIERS SIGNAUX**\n" - + ("\n".join(task_details) if task_details else "Aucun signal capturé.") + "\n\n" - f"**◈ CONCLUSION HEURISTIQUE**\n" - f"Le système est nominal. " + ("PRÉCONISATION : Vigilance accrue requise sur les membres suspects." if tense_sessions > 0 else "État de l'écosystème : Stable.") - ) - - # 4. Envoi à tous les administrateurs - if summary and self.admin_ids: - 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, "SYNTHÈSE CLINIQUE QUOTIDIENNE", summary, color=discord.Color.dark_purple()) - - # Reset pour le lendemain - self.metrics["daily_tense_sessions"] = 0 - # On pourrait purger tasks ici mais on va les garder pour l'instant (ou purger les vieilles) - self.dispatcher.tasks = {k: v for k, v in tasks.items() if v['status'] == 'PENDING' and v.get('deadline', 0) > time.time()} - self.dispatcher._save_data(self.dispatcher.tasks, os.path.join(os.path.dirname(__file__), "..", "active_tasks.json")) - - async def _voice_report_loop(self): - """Periodic broadcast of active voice session summaries.""" - await self.wait_until_ready() - while not self.is_closed(): - await asyncio.sleep(900) # Every 15 minutes - if self.voice_manager.is_monitoring and self.voice_manager.session_buffer: - report = await self.voice_manager.generate_session_report() - await self.introspection_log("VOICE SESSION UPDATE (15m)", report, discord.Color.blue()) async def on_member_join(self, member): """Handle member join.""" @@ -471,104 +325,6 @@ class Superviseur(commands.Bot): # ==================== Heuristic Engine (Risk Assessment) ==================== - def _assess_heuristic_risk(self, message) -> bool: - """ - Fast Python-side risk assessment to decide if we should AI-analyze fully. - Returns True if message is 'risky' enough to warrant LLM usage. - """ - score = 0 - content = message.content - - # CLEAN content for heuristic analysis (ignore mentions in the score) - # We use a copy to avoid modifying the original message object - scoring_content = re.sub(r'<@!?\d+>', '', content).strip() - if not scoring_content: scoring_content = content - - # 1. Content Scoring - score = 0 - - # Proactive Analysis: base score if message has substance - if len(content) > 10: - score += 15 - - scoring_content = content - scoring_content_lower = scoring_content.lower() - - # CAPS LOCK detection (> 35% caps on messages > 4 chars) - if len(scoring_content) > 4: - caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content) - if caps_ratio > 0.35: - score += 35 # Heavy weight - logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'") - - # Profanity / Trigger words (lightweight list) - triggers = [ - "raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler", - "suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free", - "@everyone", "@here", "detruire", "detruit", "destruction", "kill" - ] - for t in triggers: - if t in scoring_content_lower: - score += 50 - logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected") - break - - # Link spam detection - if "http" in scoring_content_lower: - score += 20 - - # Length anomaly - if len(scoring_content) > 300: - score += 20 - - # 3. Gibberish / Randomness detection (Entropy) - if len(scoring_content) > 8: - unique_chars = len(set(scoring_content_lower)) - diversity_ratio = unique_chars / len(scoring_content) - if diversity_ratio < 0.35: # Increased threshold - score += 25 - logger.debug(f"◈ HEURISTIC: Low diversity detected ({diversity_ratio:.2f})") - - # Too many non-alphanumeric chars (excluding spaces) - symbols = len(re.findall(r'[^a-zA-Z0-9\s]', scoring_content)) - symbol_ratio = symbols / len(scoring_content) - if symbol_ratio > 0.15: # Lowered threshold for symbols - score += 30 - logger.debug(f"◈ HEURISTIC: High symbol ratio detected ({symbol_ratio:.2f})") - - # 4. Leetspeak / Digits in words detection - if len(re.findall(r'[a-zA-Z][0-9][a-zA-Z]', scoring_content)) > 0: - score += 25 - logger.debug("◈ HEURISTIC: Leetspeak patterns detected") - - # 5. Long word detection (No spaces) - words = scoring_content.split() - if any(len(w) > 25 for w in words): - score += 30 - logger.debug("◈ HEURISTIC: Unusually long word detected") - - # 3. Mention Spam - # Fallback detection if library fails to populate message.mentions - mention_count = len(message.mentions) - if mention_count == 0: - mention_count = len(re.findall(r'<@!?\d+>', content)) - - if mention_count > 3: - score += 40 - logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)") - - # 4. Keyword Detection (Sensitivity helper) - if score < 45: - for trigger in ["analyse", "écouter", "surveille", "check"]: - if trigger in scoring_content_lower: - score += 30 - break - - # Threshold decision - # Very sensitive threshold to capture more nuances (as requested) - logger.info(f"◈ MODERATION RISK: Score Final = {score}/15") - return score >= 15 - # ==================== Moderation ==================== async def _handle_moderation(self, message): @@ -582,7 +338,7 @@ class Superviseur(commands.Bot): if len(content_low) < 3 or content_low in self.ignored_words: return - should_analyze = self._assess_heuristic_risk(message) + should_analyze = self.heuristics_manager.assess_risk(message) if not should_analyze: return @@ -627,11 +383,6 @@ class Superviseur(commands.Bot): logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)") return True - # Détection du nom "Bêta" ou "Beta" (insensible à la casse) - content_low = message.content.lower() - if "bêta" in content_low or "beta" in content_low: - logger.info("◈ DEBUG: Triggered by name match (Bêta/Beta)") - return True if message.reference and message.reference.resolved: if message.reference.resolved.author == self.user: @@ -690,15 +441,15 @@ class Superviseur(commands.Bot): # 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 - target_model = self.ollama_model if is_direct else self.ollama_model_fast - payload["model"] = target_model + # 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=self.ollama_timeout + 5) + 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_ollama(payload) + return await self.llm.call_llama(payload) try: self.metrics["total_llm_requests"] += 1 @@ -803,11 +554,10 @@ class Superviseur(commands.Bot): system_prompt=vocal_system_prompt, vision_model=False ) - payload["model"] = self.ollama_model async with self._request_semaphore: try: - analysis = await self.llm.call_ollama(payload) + analysis = await self.llm.call_llama(payload) if not analysis: logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.") return @@ -820,7 +570,7 @@ class Superviseur(commands.Bot): 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_ollama(payload) + analysis = await self.llm.call_llama(payload) # ---------------------------------------------------- logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...") @@ -868,7 +618,7 @@ class Superviseur(commands.Bot): def get_recent_logs(self, limit: int = 15) -> str: """Read and format recent action logs for context.""" - log_path = "actions.log" + log_path = "data/actions.log" if not os.path.exists(log_path): return "" @@ -941,10 +691,12 @@ class Superviseur(commands.Bot): ) -> dict: """Build LLM payload.""" user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})" - prompt = f"{self.system_prompt}\n{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}" + # 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}" - vision_model = any(v in self.ollama_model.lower() for v in - ['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']) and message.attachments + # 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, @@ -963,7 +715,7 @@ class Superviseur(commands.Bot): # 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).strip() + 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() @@ -987,18 +739,22 @@ class Superviseur(commands.Bot): 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) + 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) @@ -1022,7 +778,7 @@ class Superviseur(commands.Bot): if not action_executed: if clean_reply: await self.messaging.reply_with_limit(message, format_mentions_in_text(clean_reply)) - elif not is_monitoring: + 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]}") @@ -1087,13 +843,14 @@ class Superviseur(commands.Bot): return action = item['action'] - if action == 'NONE': - 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 - else: - success, result = await self.execute_action(action, item, message) + + 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 @@ -1129,149 +886,6 @@ class Superviseur(commands.Bot): await process_item(action_data) return action_executed - - # ==================== Action Execution ==================== - - async def execute_action(self, action: str, params: Dict, message) -> (bool, str): - """Execute an action from LLM response.""" - - # --- INSIGHT HOOK (LLM generated task for Staff) --- - if action == 'INSIGHT': - # Extract content from params - content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu" - level = params.get('level', 'STAFF') - - # Update Social Score if relevant - if 'conflit' in content.lower() or 'tension' in content.lower(): - logger.debug("Insight (Tension) logged.") - - # Dispatch - # We reconstruct a minimal context if not present - 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": message.content[:200] - } - - await self.dispatcher.dispatch_insight( - content=content, - importance=params.get('importance', 2), - guild=message.guild, - context=context_data - ) - return True, None - # ----------------------------- - - # --- ALERT HOOK (LLM generated alert for Admin) --- - if action == 'ALERT': - content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide" - - # Send to Admins - embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red()) - embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}") - - for admin_id in self.admin_ids: - u = self.get_user(admin_id) - if u: await u.send(embed=embed) - - return True, None - # ----------------------------- - - # --- VOICE ACTIONS --- - if action == 'JOIN_VOICE': - # Resilience: check message author, but also attempt to find them in the guild - # if we are in a DM interaction or if cache is stale. - voice_state = getattr(message.author, 'voice', None) - - if not voice_state and self.guild_id: - guild = self.get_guild(self.guild_id) - if guild: - member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id) - if member: - voice_state = member.voice - - if voice_state and voice_state.channel: - await self.voice_manager.join_channel(voice_state.channel) - return True, f"Connecté à {voice_state.channel.name}" - else: - return False, "Vous n'êtes pas dans un salon vocal identifiable par le système." - - if action == 'LEAVE_VOICE': - await self.voice_manager.leave_current() - return True, None - - # --- PRIVACY ACTIONS --- - if action == 'FORGET_USER': - from ia.memoire import delete_user_memory - delete_user_memory(message.author.id) - return True, None - - # Check whitelist - if self.whitelist_manager.whitelist: - required_level = self.whitelist_manager.get_required_level(action) - if required_level and not self.has_whitelist_permission(message.author.id, required_level): - return False, f"Niveau whitelist requis: {required_level}" - - action_map = { - 'KICK': 'commandes.security.kick', - 'BAN': 'commandes.security.ban', - 'UNBAN': 'commandes.security.unban', - 'UNMUTE': 'commandes.security.unmute', - 'MUTE': 'commandes.security.mute', - 'TIMEOUT': 'commandes.security.timeout', - 'PURGE': 'commandes.security.purge', - 'WARN': 'commandes.security.warn', - 'LIST_WARNINGS': 'commandes.security.list_warnings', - 'CREATE_CHANNEL': 'commandes.salons.creer', - 'DELETE_CHANNEL': 'commandes.salons.supprimer', - 'MODIFY_CHANNEL': 'commandes.salons.modifier', - 'RENAME_CHANNEL': 'commandes.salons.renommer', - 'MOVE_CHANNEL': 'commandes.salons.deplacer', - 'CREATE_ROLE': 'commandes.roles.creer', - 'DELETE_ROLE': 'commandes.roles.supprimer', - 'MODIFY_ROLE': 'commandes.roles.modifier', - 'RENAME_ROLE': 'commandes.roles.renommer', - 'MOVE_ROLE': 'commandes.roles.deplacer', - 'ADD_ROLE_TO_USER': 'commandes.roles.add_role', - 'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role', - 'CREATE_CATEGORY': 'commandes.categories.creer', - 'DELETE_CATEGORY': 'commandes.categories.supprimer', - 'MODIFY_CATEGORY': 'commandes.categories.modifier', - 'RENAME_CATEGORY': 'commandes.categories.renommer', - 'MOVE_CATEGORY': 'commandes.categories.deplacer', - 'PING': 'commandes.autres.ping', - 'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel', - 'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel', - 'SET_MUTE_ROLE': 'commandes.autres.setmuterole', - 'SEND_MESSAGE': 'commandes.autres.send_message', - 'HELP': 'commandes.autres.help', - 'STATUS': 'commandes.autres.status', - } - - module_name = action_map.get(action) - if module_name: - logger.debug(f"Importing module: {module_name}") - try: - module = self._module_cache.get(module_name) - if module is None: - module = __import__(module_name, fromlist=['execute']) - self._module_cache[module_name] = module - - logger.debug(f"Module {module_name} imported successfully") - await module.execute(self, params, message) - logger.info(f"Action '{action}' executed successfully") - return True, None - - except Exception as e: - error_msg = str(e) - logger.error(f"Error during action execution '{action}': {error_msg}") - return False, error_msg - else: - error_msg = f"Unknown action: {action}" - logger.error(error_msg) - return False, error_msg - # ==================== Metrics & Cleanup ==================== def get_metrics(self) -> Dict: diff --git a/superviseur/dispatcher.py b/superviseur/dispatcher.py index 146e93a9..a165a716 100644 --- a/superviseur/dispatcher.py +++ b/superviseur/dispatcher.py @@ -9,8 +9,8 @@ from .permissions import check_is_admin logger = logging.getLogger('Superviseur') -ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "assets_config.json") -TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "active_tasks.json") +ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json") +TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json") class AssetDispatcher: """Manages autonomous staff coordination and task assignments.""" diff --git a/superviseur/heuristic.py b/superviseur/heuristic.py index 076ffd85..a2084f90 100644 --- a/superviseur/heuristic.py +++ b/superviseur/heuristic.py @@ -1,4 +1,15 @@ - def _assess_heuristic_risk(self, message) -> bool: +import logging +import re + +logger = logging.getLogger('Superviseur') + +class HeuristicsManager: + """Handles parsing and computing heuristic risk scores for messages.""" + + def __init__(self, bot): + self.bot = bot + + def assess_risk(self, message) -> bool: """ Fast Python-side risk assessment to decide if we should AI-analyze fully. Returns True if message is 'risky' enough to warrant LLM usage. @@ -6,41 +17,92 @@ score = 0 content = message.content - # 1. Social Score Factor (Base scrutiny) - # If user is Suspect (<40), we start with higher risk score - social_data = self.social_manager.get_user_data(message.author.id) - reliability = social_data.get('score', 50) + # CLEAN content for heuristic analysis (ignore mentions in the score) + # We use a copy to avoid modifying the original message object + scoring_content = re.sub(r'<@!?\d+>', '', content).strip() + if not scoring_content: scoring_content = content - if reliability < 30: score += 40 # Very suspicious user -> almost auto-check - elif reliability < 50: score += 20 + # 1. Content Scoring + score = 0 - # 2. Pattern Matching (Fast Regex/Keywords) - content_lower = content.lower() - - # CAPS LOCK detection (> 60% caps on long messages) + # Proactive Analysis: base score if message has substance if len(content) > 10: - caps_ratio = sum(1 for c in content if c.isupper()) / len(content) - if caps_ratio > 0.6: score += 30 + score += 15 + + scoring_content = content + scoring_content_lower = scoring_content.lower() + + # CAPS LOCK detection (> 35% caps on messages > 4 chars) + if len(scoring_content) > 4: + caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content) + if caps_ratio > 0.35: + score += 35 # Heavy weight + logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'") # Profanity / Trigger words (lightweight list) - triggers = ["raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler", "suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free", "@everyone", "@here"] + triggers = [ + "raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler", + "suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free", + "@everyone", "@here", "detruire", "detruit", "destruction", "kill" + ] for t in triggers: - if t in content_lower: + if t in scoring_content_lower: score += 50 + logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected") break # Link spam detection - if "http" in content_lower: - score += 15 - - # Length anomaly (very long messages often rants) - if len(content) > 400: + if "http" in scoring_content_lower: score += 20 - # 3. Mention Spam - if len(message.mentions) > 3: - score += 40 + # Length anomaly + if len(scoring_content) > 300: + score += 20 + # 3. Gibberish / Randomness detection (Entropy) + if len(scoring_content) > 8: + unique_chars = len(set(scoring_content_lower)) + diversity_ratio = unique_chars / len(scoring_content) + if diversity_ratio < 0.35: # Increased threshold + score += 25 + logger.debug(f"◈ HEURISTIC: Low diversity detected ({diversity_ratio:.2f})") + + # Too many non-alphanumeric chars (excluding spaces) + symbols = len(re.findall(r'[^a-zA-Z0-9\s]', scoring_content)) + symbol_ratio = symbols / len(scoring_content) + if symbol_ratio > 0.15: # Lowered threshold for symbols + score += 30 + logger.debug(f"◈ HEURISTIC: High symbol ratio detected ({symbol_ratio:.2f})") + + # 4. Leetspeak / Digits in words detection + if len(re.findall(r'[a-zA-Z][0-9][a-zA-Z]', scoring_content)) > 0: + score += 25 + logger.debug("◈ HEURISTIC: Leetspeak patterns detected") + + # 5. Long word detection (No spaces) + words = scoring_content.split() + if any(len(w) > 25 for w in words): + score += 30 + logger.debug("◈ HEURISTIC: Unusually long word detected") + + # 3. Mention Spam + # Fallback detection if library fails to populate message.mentions + mention_count = len(message.mentions) + if mention_count == 0: + mention_count = len(re.findall(r'<@!?\d+>', content)) + + if mention_count > 3: + score += 40 + logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)") + + # 4. Keyword Detection (Sensitivity helper) + if score < 45: + for trigger in ["analyse", "écouter", "surveille", "check"]: + if trigger in scoring_content_lower: + score += 30 + break + # Threshold decision - # If score > 45, we analyze. - return score >= 45 + # Very sensitive threshold to capture more nuances (as requested) + logger.info(f"◈ MODERATION RISK: Score Final = {score}/15") + return score >= 15 diff --git a/superviseur/tasks.py b/superviseur/tasks.py new file mode 100644 index 00000000..2fb5e03e --- /dev/null +++ b/superviseur/tasks.py @@ -0,0 +1,159 @@ +import asyncio +import datetime +import logging +import time +import os +import discord + +logger = logging.getLogger('Superviseur') + +class TaskManager: + """Handles global background tasks, scheduled loops and reporting.""" + + def __init__(self, bot): + self.bot = bot + + def start_all(self): + """Starts all background loops attached to the bot's event loop.""" + self.bot.loop.create_task(self._introspection_report_loop()) + self.bot.loop.create_task(self._voice_report_loop()) + self.bot.loop.create_task(self._daily_report_check_loop()) + self.bot.loop.create_task(self._gdpr_purge_loop()) + self.bot.loop.create_task(self._voice_evaluation_loop()) + + # Start interviews for missing assets + for asset_id in self.bot.asset_ids: + if str(asset_id) not in self.bot.dispatcher.assets: + self.bot.loop.create_task(self.bot.dispatcher.start_interview(asset_id)) + + async def _voice_evaluation_loop(self): + """Periodic check for tactical voice connections.""" + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + for guild in self.bot.guilds: + await self.bot.voice_manager.evaluate_and_connect(guild) + await asyncio.sleep(30) # Reduit à 30s pour plus de réactivité en tâche de fond + + async def _introspection_report_loop(self): + """Periodic broadcast of system health metrics.""" + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + await asyncio.sleep(600) # Every 10 minutes + await self._send_health_report() + + async def _send_health_report(self): + """Build and send a clinical health report embed.""" + total = self.bot.metrics["total_llm_requests"] + success = self.bot.metrics["total_llm_success"] + rate = (success / total * 100) if total > 0 else 100 + avg_time = (self.bot.metrics["total_llm_time"] / success) if success > 0 else 0 + + status_report = ( + f"**◈ INTELLIGENCE CORE**\n" + f"- Requests Processed : `{total}`\n" + f"- Reliability Rate : `{rate:.1f}%`\n" + f"- Latency Heuristic : `{avg_time:.2f}s` (Target: < 5s)\n\n" + f"**◈ TACTICAL OVERVIEW**\n" + f"- Voice Protocol : `READY`\n" + f"- Active Deployment : `{self.bot.voice_manager.current_channel.name if self.bot.voice_manager.current_channel else 'NONE'}`\n" + ) + + await self.bot.introspection_log( + "SYSTEM STATUS REPORT", + status_report, + discord.Color.dark_blue() + ) + + async def _daily_report_check_loop(self): + """Check for 21:30 and trigger the daily report using local timezone.""" + await self.bot.wait_until_ready() + report_sent_today = False + while not self.bot.is_closed(): + now = datetime.datetime.now(self.bot.timezone) + if now.hour == 21 and now.minute == 30: + if not report_sent_today: + await self._send_daily_summary() + report_sent_today = True + else: + report_sent_today = False + await asyncio.sleep(60) + + async def _gdpr_purge_loop(self): + """Daily purge of old memory files (30 days) for GDPR compliance.""" + from ia.memoire import purge_old_memories + while not self.bot.is_closed(): + now = datetime.datetime.now(self.bot.timezone) + if now.hour == 3 and now.minute == 0: + logger.info("◈ RGPD: Démarrage de la purge quotidienne...") + deleted = await purge_old_memories(days=30) + logger.info(f"◈ RGPD: Purge terminée. {deleted} fichiers supprimés.") + await asyncio.sleep(65) + await asyncio.sleep(30) + + async def _send_daily_summary(self): + """Generate and send a dense, highly detailed clinical report at 21:30.""" + tasks = self.bot.dispatcher.tasks + tense_sessions = self.bot.metrics.get("daily_tense_sessions", 0) + + asset_stats = {} + for t_id, t_data in tasks.items(): + a_id = t_data.get("asset_id") + if a_id not in asset_stats: + asset_name = "Inconnu" + if str(a_id) in self.bot.dispatcher.assets: + asset_name = self.bot.dispatcher.assets[str(a_id)].get("name", "Anonyme") + asset_stats[a_id] = {"name": asset_name, "accepted": 0, "missed": 0, "total": 0} + + asset_stats[a_id]["total"] += 1 + if t_data["status"] == "ACCEPTED": + asset_stats[a_id]["accepted"] += 1 + elif t_data["status"] == "PENDING" and t_data.get("deadline", 0) < time.time(): + asset_stats[a_id]["missed"] += 1 + + stats_lines = [] + for a_id, s in asset_stats.items(): + stats_lines.append(f"- **{s['name']}** : `{s['accepted']}/{s['total']}` acceptées, `{s['missed']}` manquées.") + + task_details = [] + for t_id, t_data in list(tasks.items())[-15:]: + status_emoji = "✅" if t_data["status"] == "ACCEPTED" else "❌" if t_data.get("deadline", 0) < time.time() else "⏳" + asset_name = self.bot.dispatcher.assets.get(str(t_data['asset_id']), {}).get('name', 'N/A') + task_details.append(f"{status_emoji} `{t_id}` | Staff: {asset_name} | Urgence: {t_data['importance']} | Context: {t_data['content'][:50]}...") + + summary = ( + f"**◈ RAPPORT ANALYTIQUE DE FIN DE CYCLE ◈**\n" + f"Période : Dernières 24h | Heure : 21h30\n\n" + f"**◈ BILAN GLOBAL DES OPÉRATIONS**\n" + f"- Volume total d'actions de modération : `{len(tasks)}` \n" + f"- Sessions vocales sous tension : `{tense_sessions}`\n\n" + f"**◈ PERFORMANCE DES ASSETS**\n" + + ("\n".join(stats_lines) if stats_lines else "Aucune activité de staff enregistrée.") + "\n\n" + f"**◈ LOG DÉTAILLÉ DES DERNIERS SIGNAUX**\n" + + ("\n".join(task_details) if task_details else "Aucun signal capturé.") + "\n\n" + f"**◈ CONCLUSION HEURISTIQUE**\n" + f"Le système est nominal. " + ("PRÉCONISATION : Vigilance accrue requise sur les membres suspects." if tense_sessions > 0 else "État de l'écosystème : Stable.") + ) + + if summary and self.bot.admin_ids: + 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, "SYNTHÈSE CLINIQUE QUOTIDIENNE", summary, color=discord.Color.dark_purple()) + + self.bot.metrics["daily_tense_sessions"] = 0 + self.bot.dispatcher.tasks = {k: v for k, v in tasks.items() if v['status'] == 'PENDING' and v.get('deadline', 0) > time.time()} + + # Le chemin de Active tasks doit être vers /data si on l'a déplacé. + # Pour l'instant, on sauvegarde dans data/ + data_dir = os.path.join(os.path.dirname(__file__), "..", "data") + os.makedirs(data_dir, exist_ok=True) + self.bot.dispatcher._save_data(self.bot.dispatcher.tasks, os.path.join(data_dir, "active_tasks.json")) + + async def _voice_report_loop(self): + """Periodic broadcast of active voice session summaries.""" + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + await asyncio.sleep(900) # Every 15 minutes + if self.bot.voice_manager.is_monitoring and self.bot.voice_manager.session_buffer: + report = await self.bot.voice_manager.generate_session_report() + await self.bot.introspection_log("VOICE SESSION UPDATE (15m)", report, discord.Color.blue())