fix: gpt-oss:20b ollama, streaming, tableaux JSON, recherche flexible salons/categories

This commit is contained in:
pi 2026-06-13 14:46:02 +00:00
parent 14985f6dbb
commit 189d56026b
21 changed files with 2824 additions and 491 deletions

8
.env
View file

@ -3,13 +3,13 @@ GUILD_ID=1370716551485067304
# --- CONFIGURATION BÊTA --- # --- CONFIGURATION BÊTA ---
# IDs des Administrateurs (Séparés par des virgules) # 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 # IDs du Staff (Assets Primaires), séparés par des virgules
PRIMARY_ASSETS_IDS=971446412690722826 PRIMARY_ASSETS_IDS=971446412690722826
# Channel d'Introspection (Log Stream de Bêta) # Channel d'Introspection (Log Stream de Bêta)
INTROSPECTION_CHANNEL_ID=1461107327381016629 INTROSPECTION_CHANNEL_ID=1461107327381016629
# Modèles Ollama (Optimisation Radeon 8060S 64GB) # Modèle Ollama (local, pas de clé API nécessaire)
OLLAMA_MODEL_HEAVY=gpt-oss:120b OLLAMA_MODEL=gpt-oss:20b
OLLAMA_MODEL_FAST=deepseek-r1:7b OLLAMA_BASE_URL=http://localhost:11434

240
README.md Normal file
View file

@ -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.

View file

@ -4,7 +4,7 @@ from datetime import datetime
from .config import config from .config import config
class ActionLogger: 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.log_file = log_file
self.logger = logging.getLogger('ActionLogger') self.logger = logging.getLogger('ActionLogger')
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)

View file

@ -1,15 +1,52 @@
import discord 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): async def execute(bot, params, message):
categories = params.get('categories', []) categories = params.get('categories', [])
if not categories: if not categories:
# Fallback for single category
categories = [params] categories = [params]
guild = message.guild guild = message.guild
deleted_count = 0 deleted_count = 0
for category_params in categories: for category_params in categories:
category_name = category_params.get('category_name') category_name = category_params.get('category_name') or category_params.get('category_id')
category = discord.utils.find(lambda cat: cat.name.casefold() == category_name.casefold(), guild.categories) if not category_name:
continue
category = await find_category(guild, category_name)
if not category: if not category:
await message.channel.send(f"Catégorie '{category_name}' introuvable.") await message.channel.send(f"Catégorie '{category_name}' introuvable.")
continue continue
@ -19,4 +56,4 @@ async def execute(bot, params, message):
except discord.Forbidden: except discord.Forbidden:
await message.channel.send(f"Je n'ai pas les permissions pour supprimer la catégorie '{category_name}'.") await message.channel.send(f"Je n'ai pas les permissions pour supprimer la catégorie '{category_name}'.")
if deleted_count > 0: 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).")

View file

@ -1,15 +1,66 @@
import discord 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): async def execute(bot, params, message):
channels = params.get('channels', []) channels = params.get('channels', [])
if not channels: if not channels:
# Fallback for single channel
channels = [params] channels = [params]
guild = message.guild guild = message.guild
deleted_count = 0 deleted_count = 0
for channel_params in channels: for channel_params in channels:
channel_name = channel_params.get('channel_name') # Support both channel_id and channel_name
channel = discord.utils.find(lambda c: c.name.casefold() == channel_name.casefold(), guild.channels) 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: if not channel:
await message.channel.send(f"Salon '{channel_name}' introuvable.") await message.channel.send(f"Salon '{channel_name}' introuvable.")
continue continue

View file

@ -9,7 +9,7 @@ import json
import os import os
from datetime import datetime from datetime import datetime
WARNINGS_FILE = 'warnings.json' WARNINGS_FILE = 'data/warnings.json'
def load_warnings(): def load_warnings():
"""Charge les avertissements depuis le fichier JSON""" """Charge les avertissements depuis le fichier JSON"""

View file

@ -2,7 +2,7 @@ import discord
import json import json
import os import os
WARNINGS_FILE = 'warnings.json' WARNINGS_FILE = 'data/warnings.json'
def load_warnings(): def load_warnings():
if os.path.exists(WARNINGS_FILE): if os.path.exists(WARNINGS_FILE):

View file

@ -4,7 +4,7 @@ import os
import aiohttp import aiohttp
from datetime import datetime from datetime import datetime
WARNINGS_FILE = 'warnings.json' WARNINGS_FILE = 'data/warnings.json'
def load_warnings(): def load_warnings():
if os.path.exists(WARNINGS_FILE): if os.path.exists(WARNINGS_FILE):
@ -123,7 +123,9 @@ async def execute(bot, params, message):
target_mention = warn_params.get('target_user_mention') target_mention = warn_params.get('target_user_mention')
original_reason = warn_params.get('reason', 'Aucune raison spécifiée') original_reason = warn_params.get('reason', 'Aucune raison spécifiée')
# Rephrase the reason using Ollama # 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 reason = rephrased_reason if rephrased_reason else original_reason
# Parse mention or ID # Parse mention or ID
if target_mention.startswith('<@') and target_mention.endswith('>'): if target_mention.startswith('<@') and target_mention.endswith('>'):
@ -168,7 +170,7 @@ async def execute(bot, params, message):
) )
await message.channel.send(embed=embed) await message.channel.send(embed=embed)
# Generate full DM message using AI with original reason for polite reformulation # 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}." dm_content = dm_message if dm_message else f"Avertissement : {reason}. Nombre total : {warning_count}."
# Send DM to user # Send DM to user
try: try:

158
core/action_router.py Normal file
View file

@ -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

914
core/bot.py Normal file
View file

@ -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()

93
core/context.py Normal file
View file

@ -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

234
core/dispatcher.py Normal file
View file

@ -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)

160
core/llm.py Normal file
View file

@ -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

370
core/voice.py Normal file
View file

@ -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()
)

93
main.py Normal file
View file

@ -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())

View file

@ -1,17 +1,19 @@
PyNaCl # Core dependencies for Superviseur bot
discord.py>=2.3.0 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 python-dotenv>=1.0.0
psutil>=5.9.0 aiohttp>=3.8.0
faster-whisper aiofiles>=23.0.0
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv pytz>=2023.3
pytz 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

View file

@ -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

View file

@ -21,6 +21,9 @@ from .commands import setup_commands
from .voice import VoiceManager from .voice import VoiceManager
from .voice import VoiceManager from .voice import VoiceManager
from .dispatcher import AssetDispatcher from .dispatcher import AssetDispatcher
from .tasks import TaskManager
from .action_router import ActionRouter
from .heuristic import HeuristicsManager
import datetime import datetime
import pytz import pytz
@ -58,29 +61,21 @@ class Superviseur(commands.Bot):
def __init__( def __init__(
self, self,
guild_id: int, guild_id: int,
ollama_api_url: str, llama_model,
ollama_model: str,
ollama_timeout: int,
system_prompt: str, system_prompt: str,
command_prefix: str, command_prefix: str,
intents: discord.Intents, intents: discord.Intents,
ollama_temperature: float = 0.7, temperature: float = 0.4,
ollama_api_key: Optional[str] = None, model_loaded: bool = True
ollama_moderation_model: Optional[str] = None,
ollama_model_fast: Optional[str] = None
): ):
super().__init__(command_prefix=command_prefix, intents=intents) super().__init__(command_prefix=command_prefix, intents=intents)
# Configuration # Configuration
self.guild_id = guild_id self.guild_id = guild_id
self.ollama_api_url = ollama_api_url self.llama_model = llama_model
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.system_prompt = system_prompt self.system_prompt = system_prompt
self.ollama_temperature = ollama_temperature self.temperature = temperature
self.ollama_api_key = ollama_api_key self.model_loaded = model_loaded
# Identity # Identity
self.system_name = "Bêta" self.system_name = "Bêta"
@ -142,14 +137,15 @@ class Superviseur(commands.Bot):
# LLM # LLM
self.llm = LLMManager( self.llm = LLMManager(
ollama_api_url=ollama_api_url, llama_model=llama_model,
ollama_model=ollama_model, temperature=temperature
ollama_timeout=ollama_timeout,
ollama_temperature=ollama_temperature
) )
# Dispatcher # Dispatcher & Sub-managers
self.dispatcher = AssetDispatcher(self) 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) # Logging avec fuseau horaire local (Europe/Paris)
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "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) os.makedirs(guild_dir, exist_ok=True)
print(f'Memory directory created for guild {guild.name}: {guild_dir}') print(f'Memory directory created for guild {guild.name}: {guild_dir}')
# Setup HTTP session # Setup HTTP session (not needed for llama-cpp-python, but kept for compatibility)
await self.llm.get_session() pass
# Setup Redis # Setup Redis
# Start background tasks # Start background tasks
await self.voice_manager.initialize() await self.voice_manager.initialize()
self.loop.create_task(self._introspection_report_loop()) self.task_manager.start_all()
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))
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): async def on_member_join(self, member):
"""Handle member join.""" """Handle member join."""
@ -471,104 +325,6 @@ class Superviseur(commands.Bot):
# ==================== Heuristic Engine (Risk Assessment) ==================== # ==================== 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 ==================== # ==================== Moderation ====================
async def _handle_moderation(self, message): 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: if len(content_low) < 3 or content_low in self.ignored_words:
return return
should_analyze = self._assess_heuristic_risk(message) should_analyze = self.heuristics_manager.assess_risk(message)
if not should_analyze: if not should_analyze:
return return
@ -627,11 +383,6 @@ class Superviseur(commands.Bot):
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)") logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
return True 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 and message.reference.resolved:
if message.reference.resolved.author == self.user: 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 HEAVY pour Admin/Asset ou si on lui parle directement
# Modèle FAST pour le monitoring pur (unloads the server) # Modèle FAST pour le monitoring pur (unloads the server)
is_direct = self._should_respond(message) or message.author.id in self.admin_ids 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 # For llama-cpp-python, we don't need to specify different models in payload
payload["model"] = target_model # The model is already set in the LLMManager initialization
async def run_call(): async def run_call():
if self.redis_worker: 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 "" return resp.get("result", "") if resp else ""
else: else:
return await self.llm.call_ollama(payload) return await self.llm.call_llama(payload)
try: try:
self.metrics["total_llm_requests"] += 1 self.metrics["total_llm_requests"] += 1
@ -803,11 +554,10 @@ class Superviseur(commands.Bot):
system_prompt=vocal_system_prompt, system_prompt=vocal_system_prompt,
vision_model=False vision_model=False
) )
payload["model"] = self.ollama_model
async with self._request_semaphore: async with self._request_semaphore:
try: try:
analysis = await self.llm.call_ollama(payload) analysis = await self.llm.call_llama(payload)
if not analysis: if not analysis:
logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.") logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.")
return 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}" 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 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]}...") 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: def get_recent_logs(self, limit: int = 15) -> str:
"""Read and format recent action logs for context.""" """Read and format recent action logs for context."""
log_path = "actions.log" log_path = "data/actions.log"
if not os.path.exists(log_path): if not os.path.exists(log_path):
return "" return ""
@ -941,10 +691,12 @@ class Superviseur(commands.Bot):
) -> dict: ) -> dict:
"""Build LLM payload.""" """Build LLM payload."""
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})" 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 # For llama-cpp-python, we don't need to check for vision models in the same way
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']) and message.attachments # Vision support would need to be handled differently with llama-cpp-python
vision_model = False # Simplified for now
return self.llm.build_payload( return self.llm.build_payload(
prompt=prompt, prompt=prompt,
@ -963,7 +715,7 @@ class Superviseur(commands.Bot):
# 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique" # 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique"
if json_str: 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 # 2. Nettoyage des éventuels backticks de code et résidus de JSON
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip() clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
@ -987,18 +739,22 @@ class Superviseur(commands.Bot):
clean_reply = clean_reply.replace(marker, "") clean_reply = clean_reply.replace(marker, "")
# 4. Supprimer les résidus JSON rémanents avec un regex plus large # 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 # 5. Supprimer les lignes vides en trop et les espaces multiples
clean_reply = re.sub(r'\n{3,}', '\n\n', clean_reply) clean_reply = re.sub(r'\n{3,}', '\n\n', clean_reply)
clean_reply = clean_reply.strip() clean_reply = clean_reply.strip()
is_explicit_none = False
if json_str: if json_str:
try: try:
actions = json.loads(json_str) actions = json.loads(json_str)
if isinstance(actions, dict): actions = [actions] if isinstance(actions, dict): actions = [actions]
for action_data in 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) # Traitement Alerte/Insight (Toujours en DM)
await self._handle_beta_signals(action_data, message) await self._handle_beta_signals(action_data, message)
@ -1022,7 +778,7 @@ class Superviseur(commands.Bot):
if not action_executed: if not action_executed:
if clean_reply: if clean_reply:
await self.messaging.reply_with_limit(message, format_mentions_in_text(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 # Log the raw response to understand why parsing failed
raw_snippet = accumulated_reply.strip() raw_snippet = accumulated_reply.strip()
logger.warning(f"◈ SYSTEM: Failed to parse action or empty response. Raw output: {raw_snippet[:200]}") 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 return
action = item['action'] action = item['action']
if action == 'NONE':
resp = item.get('response', "").strip() resp = item.get('response', "").strip()
if resp and not is_monitoring: if resp and not is_monitoring:
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp)) await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
action_executed = True action_executed = True
else:
success, result = await self.execute_action(action, item, message) 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) action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)
# Feedback to user for systemic actions if not in monitoring # Feedback to user for systemic actions if not in monitoring
@ -1129,149 +886,6 @@ class Superviseur(commands.Bot):
await process_item(action_data) await process_item(action_data)
return action_executed 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 ==================== # ==================== Metrics & Cleanup ====================
def get_metrics(self) -> Dict: def get_metrics(self) -> Dict:

View file

@ -9,8 +9,8 @@ from .permissions import check_is_admin
logger = logging.getLogger('Superviseur') logger = logging.getLogger('Superviseur')
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "assets_config.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__), "..", "active_tasks.json") TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json")
class AssetDispatcher: class AssetDispatcher:
"""Manages autonomous staff coordination and task assignments.""" """Manages autonomous staff coordination and task assignments."""

View file

@ -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. Fast Python-side risk assessment to decide if we should AI-analyze fully.
Returns True if message is 'risky' enough to warrant LLM usage. Returns True if message is 'risky' enough to warrant LLM usage.
@ -6,41 +17,92 @@
score = 0 score = 0
content = message.content content = message.content
# 1. Social Score Factor (Base scrutiny) # CLEAN content for heuristic analysis (ignore mentions in the score)
# If user is Suspect (<40), we start with higher risk score # We use a copy to avoid modifying the original message object
social_data = self.social_manager.get_user_data(message.author.id) scoring_content = re.sub(r'<@!?\d+>', '', content).strip()
reliability = social_data.get('score', 50) if not scoring_content: scoring_content = content
if reliability < 30: score += 40 # Very suspicious user -> almost auto-check # 1. Content Scoring
elif reliability < 50: score += 20 score = 0
# 2. Pattern Matching (Fast Regex/Keywords) # Proactive Analysis: base score if message has substance
content_lower = content.lower()
# CAPS LOCK detection (> 60% caps on long messages)
if len(content) > 10: if len(content) > 10:
caps_ratio = sum(1 for c in content if c.isupper()) / len(content) score += 15
if caps_ratio > 0.6: score += 30
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) # 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: for t in triggers:
if t in content_lower: if t in scoring_content_lower:
score += 50 score += 50
logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected")
break break
# Link spam detection # Link spam detection
if "http" in content_lower: if "http" in scoring_content_lower:
score += 15
# Length anomaly (very long messages often rants)
if len(content) > 400:
score += 20 score += 20
# 3. Mention Spam # Length anomaly
if len(message.mentions) > 3: if len(scoring_content) > 300:
score += 40 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 # Threshold decision
# If score > 45, we analyze. # Very sensitive threshold to capture more nuances (as requested)
return score >= 45 logger.info(f"◈ MODERATION RISK: Score Final = {score}/15")
return score >= 15

159
superviseur/tasks.py Normal file
View file

@ -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())