fix: gpt-oss:20b ollama, streaming, tableaux JSON, recherche flexible salons/categories
This commit is contained in:
parent
14985f6dbb
commit
189d56026b
21 changed files with 2824 additions and 491 deletions
8
.env
8
.env
|
|
@ -3,13 +3,13 @@ GUILD_ID=1370716551485067304
|
|||
|
||||
# --- CONFIGURATION BÊTA ---
|
||||
# IDs des Administrateurs (Séparés par des virgules)
|
||||
ADMIN_IDS=971446412690722826
|
||||
ADMIN_IDS=971446412690722826,1324439906046574693
|
||||
|
||||
# IDs du Staff (Assets Primaires), séparés par des virgules
|
||||
PRIMARY_ASSETS_IDS=971446412690722826
|
||||
# Channel d'Introspection (Log Stream de Bêta)
|
||||
INTROSPECTION_CHANNEL_ID=1461107327381016629
|
||||
|
||||
# Modèles Ollama (Optimisation Radeon 8060S 64GB)
|
||||
OLLAMA_MODEL_HEAVY=gpt-oss:120b
|
||||
OLLAMA_MODEL_FAST=deepseek-r1:7b
|
||||
# Modèle Ollama (local, pas de clé API nécessaire)
|
||||
OLLAMA_MODEL=gpt-oss:20b
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
|
|
|||
240
README.md
Normal file
240
README.md
Normal 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.
|
||||
|
|
@ -4,7 +4,7 @@ from datetime import datetime
|
|||
from .config import config
|
||||
|
||||
class ActionLogger:
|
||||
def __init__(self, log_file: str = 'actions.log'):
|
||||
def __init__(self, log_file: str = 'data/actions.log'):
|
||||
self.log_file = log_file
|
||||
self.logger = logging.getLogger('ActionLogger')
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,52 @@
|
|||
import discord
|
||||
import re
|
||||
|
||||
def strip_emoji(text):
|
||||
"""Remove emoji characters for fuzzy matching."""
|
||||
emoji_pattern = re.compile("["
|
||||
u"\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF"
|
||||
u"\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U000024C2-\U0001F251"
|
||||
u"\U0000FE00-\U0000FE0F\U0000200D\U0000E0100-\U0000E01EFF"
|
||||
u"\u20E3\u00A9\u00AE\u2122"
|
||||
"]+", flags=re.UNICODE)
|
||||
return emoji_pattern.sub('', text).strip()
|
||||
|
||||
async def find_category(guild, category_name_or_id):
|
||||
"""Find a category by ID or name (exact then fuzzy)."""
|
||||
# 1. Try by ID
|
||||
try:
|
||||
cat_id = int(category_name_or_id)
|
||||
cat = guild.get_channel(cat_id)
|
||||
if cat and isinstance(cat, discord.CategoryChannel):
|
||||
return cat
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. Exact name
|
||||
for cat in guild.categories:
|
||||
if cat.name.casefold() == category_name_or_id.casefold():
|
||||
return cat
|
||||
|
||||
# 3. Fuzzy (strip emoji)
|
||||
clean_search = strip_emoji(category_name_or_id).lower().strip()
|
||||
for cat in guild.categories:
|
||||
clean_name = strip_emoji(cat.name).lower().strip()
|
||||
if clean_name == clean_search or (clean_search and clean_search in clean_name):
|
||||
return cat
|
||||
|
||||
return None
|
||||
|
||||
async def execute(bot, params, message):
|
||||
categories = params.get('categories', [])
|
||||
if not categories:
|
||||
# Fallback for single category
|
||||
categories = [params]
|
||||
guild = message.guild
|
||||
deleted_count = 0
|
||||
for category_params in categories:
|
||||
category_name = category_params.get('category_name')
|
||||
category = discord.utils.find(lambda cat: cat.name.casefold() == category_name.casefold(), guild.categories)
|
||||
category_name = category_params.get('category_name') or category_params.get('category_id')
|
||||
if not category_name:
|
||||
continue
|
||||
category = await find_category(guild, category_name)
|
||||
if not category:
|
||||
await message.channel.send(f"Catégorie '{category_name}' introuvable.")
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,15 +1,66 @@
|
|||
import discord
|
||||
import re
|
||||
|
||||
def strip_emoji(text):
|
||||
"""Remove emoji characters from channel/role names for fuzzy matching."""
|
||||
emoji_pattern = re.compile("["
|
||||
u"\U0001F600-\U0001F64F" # emoticons
|
||||
u"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||
u"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
u"\U0001F1E0-\U0001F1FF" # flags (iOS)
|
||||
u"\U00002702-\U000027B0" # dingbats
|
||||
u"\U000024C2-\U0001F251" # misc
|
||||
u"\U0000FE00-\U0000FE0F" # variation selectors
|
||||
u"\U0000200D" # zero width joiner
|
||||
u"\U0000E0100-\U0000E01EFF" # variation selectors supplement
|
||||
u"\u20E3" # combining enclosing keycap
|
||||
u"\u00A9\u00AE\u2122" # copyright, registered, trademark
|
||||
"]+", flags=re.UNICODE)
|
||||
return emoji_pattern.sub('', text).strip()
|
||||
|
||||
async def find_channel(guild, channel_name_or_id):
|
||||
"""Find a channel by ID first, then by name (exact, then flexible)."""
|
||||
# 1. Try by ID
|
||||
try:
|
||||
ch_id = int(channel_name_or_id)
|
||||
channel = guild.get_channel(ch_id)
|
||||
if channel:
|
||||
return channel
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. Try exact name match (case-insensitive)
|
||||
for ch in guild.channels:
|
||||
if ch.name.casefold() == channel_name_or_id.casefold():
|
||||
return ch
|
||||
|
||||
# 3. Try fuzzy match (strip emojis from both sides)
|
||||
clean_search = strip_emoji(channel_name_or_id).lower().strip()
|
||||
for ch in guild.channels:
|
||||
clean_name = strip_emoji(ch.name).lower().strip()
|
||||
if clean_name == clean_search:
|
||||
return ch
|
||||
# 4. If the search name is contained in the channel name
|
||||
if clean_search and clean_search in clean_name:
|
||||
return ch
|
||||
# 5. Or the channel name is contained in the search
|
||||
if clean_search and clean_name in clean_search:
|
||||
return ch
|
||||
|
||||
return None
|
||||
|
||||
async def execute(bot, params, message):
|
||||
channels = params.get('channels', [])
|
||||
if not channels:
|
||||
# Fallback for single channel
|
||||
channels = [params]
|
||||
guild = message.guild
|
||||
deleted_count = 0
|
||||
for channel_params in channels:
|
||||
channel_name = channel_params.get('channel_name')
|
||||
channel = discord.utils.find(lambda c: c.name.casefold() == channel_name.casefold(), guild.channels)
|
||||
# Support both channel_id and channel_name
|
||||
channel_name = channel_params.get('channel_name') or channel_params.get('channel_id')
|
||||
if not channel_name:
|
||||
continue # skip if no name or id
|
||||
channel = await find_channel(guild, channel_name)
|
||||
if not channel:
|
||||
await message.channel.send(f"Salon '{channel_name}' introuvable.")
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import json
|
|||
import os
|
||||
from datetime import datetime
|
||||
|
||||
WARNINGS_FILE = 'warnings.json'
|
||||
WARNINGS_FILE = 'data/warnings.json'
|
||||
|
||||
def load_warnings():
|
||||
"""Charge les avertissements depuis le fichier JSON"""
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import discord
|
|||
import json
|
||||
import os
|
||||
|
||||
WARNINGS_FILE = 'warnings.json'
|
||||
WARNINGS_FILE = 'data/warnings.json'
|
||||
|
||||
def load_warnings():
|
||||
if os.path.exists(WARNINGS_FILE):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import os
|
|||
import aiohttp
|
||||
from datetime import datetime
|
||||
|
||||
WARNINGS_FILE = 'warnings.json'
|
||||
WARNINGS_FILE = 'data/warnings.json'
|
||||
|
||||
def load_warnings():
|
||||
if os.path.exists(WARNINGS_FILE):
|
||||
|
|
@ -123,7 +123,9 @@ async def execute(bot, params, message):
|
|||
target_mention = warn_params.get('target_user_mention')
|
||||
original_reason = warn_params.get('reason', 'Aucune raison spécifiée')
|
||||
# Rephrase the reason using Ollama
|
||||
rephrased_reason = await rephrase_reason(original_reason, bot.ollama_api_url, bot.ollama_api_key, bot.ollama_model, bot.ollama_timeout, bot.ollama_temperature)
|
||||
ollama_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/generate"
|
||||
ollama_model = os.environ.get("OLLAMA_MODEL", "gpt-oss:20b")
|
||||
rephrased_reason = await rephrase_reason(original_reason, ollama_url, "", ollama_model, 30, 0.7)
|
||||
reason = rephrased_reason if rephrased_reason else original_reason
|
||||
# Parse mention or ID
|
||||
if target_mention.startswith('<@') and target_mention.endswith('>'):
|
||||
|
|
@ -168,7 +170,7 @@ async def execute(bot, params, message):
|
|||
)
|
||||
await message.channel.send(embed=embed)
|
||||
# Generate full DM message using AI with original reason for polite reformulation
|
||||
dm_message = await generate_dm_message(original_reason, warning_count, bot.ollama_api_url, bot.ollama_api_key, bot.ollama_model, bot.ollama_timeout, bot.ollama_temperature)
|
||||
dm_message = await generate_dm_message(original_reason, warning_count, ollama_url, "", ollama_model, 30, 0.7)
|
||||
dm_content = dm_message if dm_message else f"Avertissement : {reason}. Nombre total : {warning_count}."
|
||||
# Send DM to user
|
||||
try:
|
||||
|
|
|
|||
158
core/action_router.py
Normal file
158
core/action_router.py
Normal 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
914
core/bot.py
Normal 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
93
core/context.py
Normal 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
234
core/dispatcher.py
Normal 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
160
core/llm.py
Normal 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
370
core/voice.py
Normal 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
93
main.py
Normal 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())
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
PyNaCl
|
||||
# Core dependencies for Superviseur bot
|
||||
discord.py>=2.3.0
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.1.0
|
||||
uvloop>=0.17.0 ; python_version >= '3.8'
|
||||
fastapi>=0.95.0
|
||||
uvicorn>=0.22.0
|
||||
prometheus_client>=0.16.0
|
||||
httpx>=0.24.0
|
||||
redis>=5.3.0
|
||||
pytest>=7.0.0
|
||||
flake8>=6.0.0
|
||||
python-dotenv>=1.0.0
|
||||
psutil>=5.9.0
|
||||
faster-whisper
|
||||
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
|
||||
pytz
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.0.0
|
||||
pytz>=2023.3
|
||||
redis>=4.5.0
|
||||
pynacl>=1.5.0
|
||||
faster-whisper>=0.10.0
|
||||
uvloop>=0.17.0; sys_platform != "win32"
|
||||
|
||||
# Optional dependencies for enhanced functionality
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
prometheus-client>=0.15.0
|
||||
|
||||
# Development and testing
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
144
superviseur/action_router.py
Normal file
144
superviseur/action_router.py
Normal 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
|
||||
|
|
@ -21,6 +21,9 @@ from .commands import setup_commands
|
|||
from .voice import VoiceManager
|
||||
from .voice import VoiceManager
|
||||
from .dispatcher import AssetDispatcher
|
||||
from .tasks import TaskManager
|
||||
from .action_router import ActionRouter
|
||||
from .heuristic import HeuristicsManager
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
|
|
@ -58,29 +61,21 @@ class Superviseur(commands.Bot):
|
|||
def __init__(
|
||||
self,
|
||||
guild_id: int,
|
||||
ollama_api_url: str,
|
||||
ollama_model: str,
|
||||
ollama_timeout: int,
|
||||
llama_model,
|
||||
system_prompt: str,
|
||||
command_prefix: str,
|
||||
intents: discord.Intents,
|
||||
ollama_temperature: float = 0.7,
|
||||
ollama_api_key: Optional[str] = None,
|
||||
ollama_moderation_model: Optional[str] = None,
|
||||
ollama_model_fast: Optional[str] = None
|
||||
temperature: float = 0.4,
|
||||
model_loaded: bool = True
|
||||
):
|
||||
super().__init__(command_prefix=command_prefix, intents=intents)
|
||||
|
||||
# Configuration
|
||||
self.guild_id = guild_id
|
||||
self.ollama_api_url = ollama_api_url
|
||||
self.ollama_model = ollama_model
|
||||
self.ollama_model_fast = ollama_model_fast or ollama_model
|
||||
self.ollama_moderation_model = ollama_moderation_model or ollama_model
|
||||
self.ollama_timeout = ollama_timeout
|
||||
self.llama_model = llama_model
|
||||
self.system_prompt = system_prompt
|
||||
self.ollama_temperature = ollama_temperature
|
||||
self.ollama_api_key = ollama_api_key
|
||||
self.temperature = temperature
|
||||
self.model_loaded = model_loaded
|
||||
|
||||
# Identity
|
||||
self.system_name = "Bêta"
|
||||
|
|
@ -142,14 +137,15 @@ class Superviseur(commands.Bot):
|
|||
|
||||
# LLM
|
||||
self.llm = LLMManager(
|
||||
ollama_api_url=ollama_api_url,
|
||||
ollama_model=ollama_model,
|
||||
ollama_timeout=ollama_timeout,
|
||||
ollama_temperature=ollama_temperature
|
||||
llama_model=llama_model,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
# Dispatcher
|
||||
# Dispatcher & Sub-managers
|
||||
self.dispatcher = AssetDispatcher(self)
|
||||
self.task_manager = TaskManager(self)
|
||||
self.action_router = ActionRouter(self)
|
||||
self.heuristics_manager = HeuristicsManager(self)
|
||||
|
||||
# Logging avec fuseau horaire local (Europe/Paris)
|
||||
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
|
||||
|
|
@ -217,156 +213,14 @@ class Superviseur(commands.Bot):
|
|||
os.makedirs(guild_dir, exist_ok=True)
|
||||
print(f'Memory directory created for guild {guild.name}: {guild_dir}')
|
||||
|
||||
# Setup HTTP session
|
||||
await self.llm.get_session()
|
||||
# Setup HTTP session (not needed for llama-cpp-python, but kept for compatibility)
|
||||
pass
|
||||
|
||||
# Setup Redis
|
||||
# Start background tasks
|
||||
await self.voice_manager.initialize()
|
||||
self.loop.create_task(self._introspection_report_loop())
|
||||
self.loop.create_task(self._voice_report_loop())
|
||||
self.loop.create_task(self._voice_report_loop())
|
||||
self.loop.create_task(self._daily_report_check_loop())
|
||||
self.loop.create_task(self._gdpr_purge_loop())
|
||||
self.task_manager.start_all()
|
||||
|
||||
# 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):
|
||||
"""Handle member join."""
|
||||
|
|
@ -471,104 +325,6 @@ class Superviseur(commands.Bot):
|
|||
|
||||
# ==================== Heuristic Engine (Risk Assessment) ====================
|
||||
|
||||
def _assess_heuristic_risk(self, message) -> bool:
|
||||
"""
|
||||
Fast Python-side risk assessment to decide if we should AI-analyze fully.
|
||||
Returns True if message is 'risky' enough to warrant LLM usage.
|
||||
"""
|
||||
score = 0
|
||||
content = message.content
|
||||
|
||||
# CLEAN content for heuristic analysis (ignore mentions in the score)
|
||||
# We use a copy to avoid modifying the original message object
|
||||
scoring_content = re.sub(r'<@!?\d+>', '', content).strip()
|
||||
if not scoring_content: scoring_content = content
|
||||
|
||||
# 1. Content Scoring
|
||||
score = 0
|
||||
|
||||
# Proactive Analysis: base score if message has substance
|
||||
if len(content) > 10:
|
||||
score += 15
|
||||
|
||||
scoring_content = content
|
||||
scoring_content_lower = scoring_content.lower()
|
||||
|
||||
# CAPS LOCK detection (> 35% caps on messages > 4 chars)
|
||||
if len(scoring_content) > 4:
|
||||
caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content)
|
||||
if caps_ratio > 0.35:
|
||||
score += 35 # Heavy weight
|
||||
logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'")
|
||||
|
||||
# Profanity / Trigger words (lightweight list)
|
||||
triggers = [
|
||||
"raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler",
|
||||
"suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free",
|
||||
"@everyone", "@here", "detruire", "detruit", "destruction", "kill"
|
||||
]
|
||||
for t in triggers:
|
||||
if t in scoring_content_lower:
|
||||
score += 50
|
||||
logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected")
|
||||
break
|
||||
|
||||
# Link spam detection
|
||||
if "http" in scoring_content_lower:
|
||||
score += 20
|
||||
|
||||
# Length anomaly
|
||||
if len(scoring_content) > 300:
|
||||
score += 20
|
||||
|
||||
# 3. Gibberish / Randomness detection (Entropy)
|
||||
if len(scoring_content) > 8:
|
||||
unique_chars = len(set(scoring_content_lower))
|
||||
diversity_ratio = unique_chars / len(scoring_content)
|
||||
if diversity_ratio < 0.35: # Increased threshold
|
||||
score += 25
|
||||
logger.debug(f"◈ HEURISTIC: Low diversity detected ({diversity_ratio:.2f})")
|
||||
|
||||
# Too many non-alphanumeric chars (excluding spaces)
|
||||
symbols = len(re.findall(r'[^a-zA-Z0-9\s]', scoring_content))
|
||||
symbol_ratio = symbols / len(scoring_content)
|
||||
if symbol_ratio > 0.15: # Lowered threshold for symbols
|
||||
score += 30
|
||||
logger.debug(f"◈ HEURISTIC: High symbol ratio detected ({symbol_ratio:.2f})")
|
||||
|
||||
# 4. Leetspeak / Digits in words detection
|
||||
if len(re.findall(r'[a-zA-Z][0-9][a-zA-Z]', scoring_content)) > 0:
|
||||
score += 25
|
||||
logger.debug("◈ HEURISTIC: Leetspeak patterns detected")
|
||||
|
||||
# 5. Long word detection (No spaces)
|
||||
words = scoring_content.split()
|
||||
if any(len(w) > 25 for w in words):
|
||||
score += 30
|
||||
logger.debug("◈ HEURISTIC: Unusually long word detected")
|
||||
|
||||
# 3. Mention Spam
|
||||
# Fallback detection if library fails to populate message.mentions
|
||||
mention_count = len(message.mentions)
|
||||
if mention_count == 0:
|
||||
mention_count = len(re.findall(r'<@!?\d+>', content))
|
||||
|
||||
if mention_count > 3:
|
||||
score += 40
|
||||
logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)")
|
||||
|
||||
# 4. Keyword Detection (Sensitivity helper)
|
||||
if score < 45:
|
||||
for trigger in ["analyse", "écouter", "surveille", "check"]:
|
||||
if trigger in scoring_content_lower:
|
||||
score += 30
|
||||
break
|
||||
|
||||
# Threshold decision
|
||||
# Very sensitive threshold to capture more nuances (as requested)
|
||||
logger.info(f"◈ MODERATION RISK: Score Final = {score}/15")
|
||||
return score >= 15
|
||||
|
||||
# ==================== Moderation ====================
|
||||
|
||||
async def _handle_moderation(self, message):
|
||||
|
|
@ -582,7 +338,7 @@ class Superviseur(commands.Bot):
|
|||
if len(content_low) < 3 or content_low in self.ignored_words:
|
||||
return
|
||||
|
||||
should_analyze = self._assess_heuristic_risk(message)
|
||||
should_analyze = self.heuristics_manager.assess_risk(message)
|
||||
if not should_analyze:
|
||||
return
|
||||
|
||||
|
|
@ -627,11 +383,6 @@ class Superviseur(commands.Bot):
|
|||
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
|
||||
return True
|
||||
|
||||
# Détection du nom "Bêta" ou "Beta" (insensible à la casse)
|
||||
content_low = message.content.lower()
|
||||
if "bêta" in content_low or "beta" in content_low:
|
||||
logger.info("◈ DEBUG: Triggered by name match (Bêta/Beta)")
|
||||
return True
|
||||
|
||||
if message.reference and message.reference.resolved:
|
||||
if message.reference.resolved.author == self.user:
|
||||
|
|
@ -690,15 +441,15 @@ class Superviseur(commands.Bot):
|
|||
# Modèle HEAVY pour Admin/Asset ou si on lui parle directement
|
||||
# Modèle FAST pour le monitoring pur (unloads the server)
|
||||
is_direct = self._should_respond(message) or message.author.id in self.admin_ids
|
||||
target_model = self.ollama_model if is_direct else self.ollama_model_fast
|
||||
payload["model"] = target_model
|
||||
# For llama-cpp-python, we don't need to specify different models in payload
|
||||
# The model is already set in the LLMManager initialization
|
||||
|
||||
async def run_call():
|
||||
if self.redis_worker:
|
||||
resp = await self.redis_worker.enqueue_job(payload, timeout=self.ollama_timeout + 5)
|
||||
resp = await self.redis_worker.enqueue_job(payload, timeout=605) # 10 minutes + 5 seconds
|
||||
return resp.get("result", "") if resp else ""
|
||||
else:
|
||||
return await self.llm.call_ollama(payload)
|
||||
return await self.llm.call_llama(payload)
|
||||
|
||||
try:
|
||||
self.metrics["total_llm_requests"] += 1
|
||||
|
|
@ -803,11 +554,10 @@ class Superviseur(commands.Bot):
|
|||
system_prompt=vocal_system_prompt,
|
||||
vision_model=False
|
||||
)
|
||||
payload["model"] = self.ollama_model
|
||||
|
||||
async with self._request_semaphore:
|
||||
try:
|
||||
analysis = await self.llm.call_ollama(payload)
|
||||
analysis = await self.llm.call_llama(payload)
|
||||
if not analysis:
|
||||
logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.")
|
||||
return
|
||||
|
|
@ -820,7 +570,7 @@ class Superviseur(commands.Bot):
|
|||
extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}"
|
||||
payload["prompt"] += extra_memory_context
|
||||
|
||||
analysis = await self.llm.call_ollama(payload)
|
||||
analysis = await self.llm.call_llama(payload)
|
||||
# ----------------------------------------------------
|
||||
|
||||
logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...")
|
||||
|
|
@ -868,7 +618,7 @@ class Superviseur(commands.Bot):
|
|||
|
||||
def get_recent_logs(self, limit: int = 15) -> str:
|
||||
"""Read and format recent action logs for context."""
|
||||
log_path = "actions.log"
|
||||
log_path = "data/actions.log"
|
||||
if not os.path.exists(log_path):
|
||||
return ""
|
||||
|
||||
|
|
@ -941,10 +691,12 @@ class Superviseur(commands.Bot):
|
|||
) -> dict:
|
||||
"""Build LLM payload."""
|
||||
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})"
|
||||
prompt = f"{self.system_prompt}\n{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}"
|
||||
# Removed redundant self.system_prompt as it's passed as system_prompt in build_payload
|
||||
prompt = f"{permissions_info}\n{user_name_info}{server_context}{channels_list}\n{history}\n\nUser: {content}"
|
||||
|
||||
vision_model = any(v in self.ollama_model.lower() for v in
|
||||
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']) and message.attachments
|
||||
# For llama-cpp-python, we don't need to check for vision models in the same way
|
||||
# Vision support would need to be handled differently with llama-cpp-python
|
||||
vision_model = False # Simplified for now
|
||||
|
||||
return self.llm.build_payload(
|
||||
prompt=prompt,
|
||||
|
|
@ -963,7 +715,7 @@ class Superviseur(commands.Bot):
|
|||
|
||||
# 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique"
|
||||
if json_str:
|
||||
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL).strip()
|
||||
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||
|
||||
# 2. Nettoyage des éventuels backticks de code et résidus de JSON
|
||||
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
|
||||
|
|
@ -987,18 +739,22 @@ class Superviseur(commands.Bot):
|
|||
clean_reply = clean_reply.replace(marker, "")
|
||||
|
||||
# 4. Supprimer les résidus JSON rémanents avec un regex plus large
|
||||
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL)
|
||||
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# 5. Supprimer les lignes vides en trop et les espaces multiples
|
||||
clean_reply = re.sub(r'\n{3,}', '\n\n', clean_reply)
|
||||
clean_reply = clean_reply.strip()
|
||||
|
||||
is_explicit_none = False
|
||||
if json_str:
|
||||
try:
|
||||
actions = json.loads(json_str)
|
||||
if isinstance(actions, dict): actions = [actions]
|
||||
|
||||
for action_data in actions:
|
||||
if action_data.get('action', '').upper() == 'NONE' and not action_data.get('response'):
|
||||
is_explicit_none = True
|
||||
|
||||
# Traitement Alerte/Insight (Toujours en DM)
|
||||
await self._handle_beta_signals(action_data, message)
|
||||
|
||||
|
|
@ -1022,7 +778,7 @@ class Superviseur(commands.Bot):
|
|||
if not action_executed:
|
||||
if clean_reply:
|
||||
await self.messaging.reply_with_limit(message, format_mentions_in_text(clean_reply))
|
||||
elif not is_monitoring:
|
||||
elif not is_monitoring and not is_explicit_none:
|
||||
# Log the raw response to understand why parsing failed
|
||||
raw_snippet = accumulated_reply.strip()
|
||||
logger.warning(f"◈ SYSTEM: Failed to parse action or empty response. Raw output: {raw_snippet[:200]}")
|
||||
|
|
@ -1087,13 +843,14 @@ class Superviseur(commands.Bot):
|
|||
return
|
||||
|
||||
action = item['action']
|
||||
if action == 'NONE':
|
||||
|
||||
resp = item.get('response', "").strip()
|
||||
if resp and not is_monitoring:
|
||||
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
|
||||
action_executed = True
|
||||
else:
|
||||
success, result = await self.execute_action(action, item, message)
|
||||
|
||||
if action != 'NONE':
|
||||
success, result = await self.action_router.execute_action(action, item, message)
|
||||
action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)
|
||||
|
||||
# Feedback to user for systemic actions if not in monitoring
|
||||
|
|
@ -1129,149 +886,6 @@ class Superviseur(commands.Bot):
|
|||
await process_item(action_data)
|
||||
|
||||
return action_executed
|
||||
|
||||
# ==================== Action Execution ====================
|
||||
|
||||
async def execute_action(self, action: str, params: Dict, message) -> (bool, str):
|
||||
"""Execute an action from LLM response."""
|
||||
|
||||
# --- INSIGHT HOOK (LLM generated task for Staff) ---
|
||||
if action == 'INSIGHT':
|
||||
# Extract content from params
|
||||
content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu"
|
||||
level = params.get('level', 'STAFF')
|
||||
|
||||
# Update Social Score if relevant
|
||||
if 'conflit' in content.lower() or 'tension' in content.lower():
|
||||
logger.debug("Insight (Tension) logged.")
|
||||
|
||||
# Dispatch
|
||||
# We reconstruct a minimal context if not present
|
||||
context_data = {
|
||||
"author_mention": message.author.mention,
|
||||
"author_name": message.author.display_name,
|
||||
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
|
||||
"content": message.content[:200]
|
||||
}
|
||||
|
||||
await self.dispatcher.dispatch_insight(
|
||||
content=content,
|
||||
importance=params.get('importance', 2),
|
||||
guild=message.guild,
|
||||
context=context_data
|
||||
)
|
||||
return True, None
|
||||
# -----------------------------
|
||||
|
||||
# --- ALERT HOOK (LLM generated alert for Admin) ---
|
||||
if action == 'ALERT':
|
||||
content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide"
|
||||
|
||||
# Send to Admins
|
||||
embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red())
|
||||
embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}")
|
||||
|
||||
for admin_id in self.admin_ids:
|
||||
u = self.get_user(admin_id)
|
||||
if u: await u.send(embed=embed)
|
||||
|
||||
return True, None
|
||||
# -----------------------------
|
||||
|
||||
# --- VOICE ACTIONS ---
|
||||
if action == 'JOIN_VOICE':
|
||||
# Resilience: check message author, but also attempt to find them in the guild
|
||||
# if we are in a DM interaction or if cache is stale.
|
||||
voice_state = getattr(message.author, 'voice', None)
|
||||
|
||||
if not voice_state and self.guild_id:
|
||||
guild = self.get_guild(self.guild_id)
|
||||
if guild:
|
||||
member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id)
|
||||
if member:
|
||||
voice_state = member.voice
|
||||
|
||||
if voice_state and voice_state.channel:
|
||||
await self.voice_manager.join_channel(voice_state.channel)
|
||||
return True, f"Connecté à {voice_state.channel.name}"
|
||||
else:
|
||||
return False, "Vous n'êtes pas dans un salon vocal identifiable par le système."
|
||||
|
||||
if action == 'LEAVE_VOICE':
|
||||
await self.voice_manager.leave_current()
|
||||
return True, None
|
||||
|
||||
# --- PRIVACY ACTIONS ---
|
||||
if action == 'FORGET_USER':
|
||||
from ia.memoire import delete_user_memory
|
||||
delete_user_memory(message.author.id)
|
||||
return True, None
|
||||
|
||||
# Check whitelist
|
||||
if self.whitelist_manager.whitelist:
|
||||
required_level = self.whitelist_manager.get_required_level(action)
|
||||
if required_level and not self.has_whitelist_permission(message.author.id, required_level):
|
||||
return False, f"Niveau whitelist requis: {required_level}"
|
||||
|
||||
action_map = {
|
||||
'KICK': 'commandes.security.kick',
|
||||
'BAN': 'commandes.security.ban',
|
||||
'UNBAN': 'commandes.security.unban',
|
||||
'UNMUTE': 'commandes.security.unmute',
|
||||
'MUTE': 'commandes.security.mute',
|
||||
'TIMEOUT': 'commandes.security.timeout',
|
||||
'PURGE': 'commandes.security.purge',
|
||||
'WARN': 'commandes.security.warn',
|
||||
'LIST_WARNINGS': 'commandes.security.list_warnings',
|
||||
'CREATE_CHANNEL': 'commandes.salons.creer',
|
||||
'DELETE_CHANNEL': 'commandes.salons.supprimer',
|
||||
'MODIFY_CHANNEL': 'commandes.salons.modifier',
|
||||
'RENAME_CHANNEL': 'commandes.salons.renommer',
|
||||
'MOVE_CHANNEL': 'commandes.salons.deplacer',
|
||||
'CREATE_ROLE': 'commandes.roles.creer',
|
||||
'DELETE_ROLE': 'commandes.roles.supprimer',
|
||||
'MODIFY_ROLE': 'commandes.roles.modifier',
|
||||
'RENAME_ROLE': 'commandes.roles.renommer',
|
||||
'MOVE_ROLE': 'commandes.roles.deplacer',
|
||||
'ADD_ROLE_TO_USER': 'commandes.roles.add_role',
|
||||
'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role',
|
||||
'CREATE_CATEGORY': 'commandes.categories.creer',
|
||||
'DELETE_CATEGORY': 'commandes.categories.supprimer',
|
||||
'MODIFY_CATEGORY': 'commandes.categories.modifier',
|
||||
'RENAME_CATEGORY': 'commandes.categories.renommer',
|
||||
'MOVE_CATEGORY': 'commandes.categories.deplacer',
|
||||
'PING': 'commandes.autres.ping',
|
||||
'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel',
|
||||
'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel',
|
||||
'SET_MUTE_ROLE': 'commandes.autres.setmuterole',
|
||||
'SEND_MESSAGE': 'commandes.autres.send_message',
|
||||
'HELP': 'commandes.autres.help',
|
||||
'STATUS': 'commandes.autres.status',
|
||||
}
|
||||
|
||||
module_name = action_map.get(action)
|
||||
if module_name:
|
||||
logger.debug(f"Importing module: {module_name}")
|
||||
try:
|
||||
module = self._module_cache.get(module_name)
|
||||
if module is None:
|
||||
module = __import__(module_name, fromlist=['execute'])
|
||||
self._module_cache[module_name] = module
|
||||
|
||||
logger.debug(f"Module {module_name} imported successfully")
|
||||
await module.execute(self, params, message)
|
||||
logger.info(f"Action '{action}' executed successfully")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Error during action execution '{action}': {error_msg}")
|
||||
return False, error_msg
|
||||
else:
|
||||
error_msg = f"Unknown action: {action}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# ==================== Metrics & Cleanup ====================
|
||||
|
||||
def get_metrics(self) -> Dict:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from .permissions import check_is_admin
|
|||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "assets_config.json")
|
||||
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "active_tasks.json")
|
||||
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json")
|
||||
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json")
|
||||
|
||||
class AssetDispatcher:
|
||||
"""Manages autonomous staff coordination and task assignments."""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
def _assess_heuristic_risk(self, message) -> bool:
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
class HeuristicsManager:
|
||||
"""Handles parsing and computing heuristic risk scores for messages."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
def assess_risk(self, message) -> bool:
|
||||
"""
|
||||
Fast Python-side risk assessment to decide if we should AI-analyze fully.
|
||||
Returns True if message is 'risky' enough to warrant LLM usage.
|
||||
|
|
@ -6,41 +17,92 @@
|
|||
score = 0
|
||||
content = message.content
|
||||
|
||||
# 1. Social Score Factor (Base scrutiny)
|
||||
# If user is Suspect (<40), we start with higher risk score
|
||||
social_data = self.social_manager.get_user_data(message.author.id)
|
||||
reliability = social_data.get('score', 50)
|
||||
# CLEAN content for heuristic analysis (ignore mentions in the score)
|
||||
# We use a copy to avoid modifying the original message object
|
||||
scoring_content = re.sub(r'<@!?\d+>', '', content).strip()
|
||||
if not scoring_content: scoring_content = content
|
||||
|
||||
if reliability < 30: score += 40 # Very suspicious user -> almost auto-check
|
||||
elif reliability < 50: score += 20
|
||||
# 1. Content Scoring
|
||||
score = 0
|
||||
|
||||
# 2. Pattern Matching (Fast Regex/Keywords)
|
||||
content_lower = content.lower()
|
||||
|
||||
# CAPS LOCK detection (> 60% caps on long messages)
|
||||
# Proactive Analysis: base score if message has substance
|
||||
if len(content) > 10:
|
||||
caps_ratio = sum(1 for c in content if c.isupper()) / len(content)
|
||||
if caps_ratio > 0.6: score += 30
|
||||
score += 15
|
||||
|
||||
scoring_content = content
|
||||
scoring_content_lower = scoring_content.lower()
|
||||
|
||||
# CAPS LOCK detection (> 35% caps on messages > 4 chars)
|
||||
if len(scoring_content) > 4:
|
||||
caps_ratio = sum(1 for c in scoring_content if c.isupper()) / len(scoring_content)
|
||||
if caps_ratio > 0.35:
|
||||
score += 35 # Heavy weight
|
||||
logger.debug(f"◈ HEURISTIC: CAPS detected ({caps_ratio:.2f}) on '{scoring_content[:15]}...'")
|
||||
|
||||
# Profanity / Trigger words (lightweight list)
|
||||
triggers = ["raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler", "suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free", "@everyone", "@here"]
|
||||
triggers = [
|
||||
"raid", "hack", "pute", "connard", "fdp", "tg", "merde", "nazi", "hitler",
|
||||
"suicide", "bomb", "token", "grab", "nitro", "steam", "discord.gift", "free",
|
||||
"@everyone", "@here", "detruire", "detruit", "destruction", "kill"
|
||||
]
|
||||
for t in triggers:
|
||||
if t in content_lower:
|
||||
if t in scoring_content_lower:
|
||||
score += 50
|
||||
logger.debug(f"◈ HEURISTIC: Trigger word '{t}' detected")
|
||||
break
|
||||
|
||||
# Link spam detection
|
||||
if "http" in content_lower:
|
||||
score += 15
|
||||
|
||||
# Length anomaly (very long messages often rants)
|
||||
if len(content) > 400:
|
||||
if "http" in scoring_content_lower:
|
||||
score += 20
|
||||
|
||||
# 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
|
||||
if len(message.mentions) > 3:
|
||||
# Fallback detection if library fails to populate message.mentions
|
||||
mention_count = len(message.mentions)
|
||||
if mention_count == 0:
|
||||
mention_count = len(re.findall(r'<@!?\d+>', content))
|
||||
|
||||
if mention_count > 3:
|
||||
score += 40
|
||||
logger.debug(f"◈ HEURISTIC: Mention spam detected ({mention_count} mentions)")
|
||||
|
||||
# 4. Keyword Detection (Sensitivity helper)
|
||||
if score < 45:
|
||||
for trigger in ["analyse", "écouter", "surveille", "check"]:
|
||||
if trigger in scoring_content_lower:
|
||||
score += 30
|
||||
break
|
||||
|
||||
# Threshold decision
|
||||
# If score > 45, we analyze.
|
||||
return score >= 45
|
||||
# Very sensitive threshold to capture more nuances (as requested)
|
||||
logger.info(f"◈ MODERATION RISK: Score Final = {score}/15")
|
||||
return score >= 15
|
||||
|
|
|
|||
159
superviseur/tasks.py
Normal file
159
superviseur/tasks.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue