Beta/superviseur/bot.py

1304 lines
59 KiB
Python
Raw Normal View History

2026-02-06 22:23:20 +01:00
"""Main Superviseur Bot class - refactored for better organization."""
import discord
from discord.ext import commands
import aiohttp
import asyncio
import os
import time
import json
import logging
import re
from typing import Dict, Optional
from .utils import format_mentions_in_text, sanitize_guild_name
from .permissions import WhitelistManager, check_is_admin, get_permissions_info
from .messaging import MessagingManager
from .context import build_context_for_message
from .llm import LLMManager
from .redis_worker import RedisWorker
from .commands import setup_commands
from .voice import VoiceManager
from .voice import VoiceManager
from .dispatcher import AssetDispatcher
import datetime
import pytz
from ia.memoire import add_interaction, build_context_for_model, flush_all, MEMORY_DIR
from commandes.autres.config import config
from commandes.autres.logger import action_logger
from commandes.security.auto_moderation import moderate_message
class MockMessage:
"""Mock discord.Message for voice-triggered interactions."""
def __init__(self, author, channel, content, guild=None):
self.author = author
self.channel = channel
self.content = content
self.guild = guild or (channel.guild if hasattr(channel, 'guild') else None)
self.attachments = []
self.mentions = []
self.channel_mentions = []
self.role_mentions = []
self.jump_url = ""
async def reply(self, content, **kwargs):
"""Mock reply - sends a message to the channel/user instead."""
# For vocal interaction, we don't need a prefix in DM as it's a private chat
is_dm = isinstance(self.channel, (discord.DMChannel, discord.User, discord.Member))
prefix = "" if is_dm else f"**{self.author.display_name}** (Vocal) : "
return await self.channel.send(f"{prefix}{content}", **kwargs)
logger = logging.getLogger('Superviseur')
class Superviseur(commands.Bot):
"""Main bot class for Superviseur Discord bot."""
def __init__(
self,
guild_id: int,
ollama_api_url: str,
ollama_model: str,
ollama_timeout: int,
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
):
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.system_prompt = system_prompt
self.ollama_temperature = ollama_temperature
self.ollama_api_key = ollama_api_key
# Identity
self.system_name = "Bêta"
admins_env = os.getenv("ADMIN_IDS", "")
self.admin_ids = [int(i.strip()) for i in admins_env.split(",") if i.strip().isdigit()]
self.introspection_channel_id = int(os.getenv("INTROSPECTION_CHANNEL_ID", 0))
# Assets (Staff)
assets_env = os.getenv("PRIMARY_ASSETS_IDS", "")
self.asset_ids = [int(i.strip()) for i in assets_env.split(",") if i.strip().isdigit()]
# Spam Prevention
self.insight_cooldowns = {} # {user_id: timestamp}
self.insight_cooldown_duration = 1800 # 30 minutes
self.insight_threshold = 5
self.ignored_words = {"salut", "cc", "hello", "ça va", "ca va", "bjr", "slt", "yo", "re", "ok"}
self.monitoring_cooldowns = {}
# Caches
self.channels_list_cache = {}
self.server_context_cache = {}
self._module_cache: Dict[str, object] = {}
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None
# Concurrency control
self.max_concurrent_requests = int(os.environ.get("SUPERVISEUR_MAX_CONCURRENT", 4))
self._request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
# Metrics
self.metrics = {
"total_llm_requests": 0,
"total_llm_success": 0,
"total_llm_errors": 0,
"total_llm_time": 0.0,
}
# Redis
self.aioredis = None
try:
import redis.asyncio as aioredis
self.aioredis = aioredis
except Exception:
pass
self.redis_worker: Optional[RedisWorker] = None
# Whitelist
self.whitelist_manager = WhitelistManager()
# Messaging
self.messaging = MessagingManager(self)
# Disable command prefix for natural language focus
self.command_prefix = "DISABLED_PREFIX_FOR_NL"
# Voice Management
self.voice_manager = VoiceManager(self)
# LLM
self.llm = LLMManager(
ollama_api_url=ollama_api_url,
ollama_model=ollama_model,
ollama_timeout=ollama_timeout,
ollama_temperature=ollama_temperature
)
# Dispatcher
self.dispatcher = AssetDispatcher(self)
# Logging avec fuseau horaire local (Europe/Paris)
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
def local_time_converter(*args):
return datetime.datetime.now(self.timezone).timetuple()
logging.Formatter.converter = local_time_converter
logging.basicConfig(
level=logging.INFO, # Augmenté à INFO par défaut pour le root
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Sourdine sur les logs Discord trop bavards (RTCP, Gateway)
logging.getLogger('discord').setLevel(logging.WARNING)
logging.getLogger('discord.voice_state').setLevel(logging.WARNING)
logging.getLogger('discord.gateway').setLevel(logging.WARNING)
logging.getLogger('httpx').setLevel(logging.WARNING)
logging.getLogger('huggingface_hub').setLevel(logging.WARNING)
try:
logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING)
except: pass
# ==================== Utility Methods ====================
async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()):
"""Internal telemetry log - bypassing AI to preserve resources."""
if not self.introspection_channel_id: return
channel = self.get_channel(self.introspection_channel_id) or await self.fetch_channel(self.introspection_channel_id)
if channel:
await self.messaging.send_embed(
channel,
f"INTROSPECTION: {title}",
description,
color=color
)
def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]:
"""Get user's whitelist level."""
return self.whitelist_manager.get_user_level(user_id, user_name)
def has_whitelist_permission(
self,
user_id: int,
required_level: str,
user_name: Optional[str] = None
) -> bool:
"""Check whitelist permission."""
return self.whitelist_manager.has_permission(user_id, required_level, user_name)
# ==================== Event Handlers ====================
async def on_ready(self):
"""Called when bot is ready."""
print(f'Logged in as {self.user}')
# Register commands
await setup_commands(self)
# Create memory directories
import os
for guild in self.guilds:
sanitized_name = sanitize_guild_name(guild.name)
guild_dir = os.path.join(MEMORY_DIR, sanitized_name)
os.makedirs(guild_dir, exist_ok=True)
print(f'Memory directory created for guild {guild.name}: {guild_dir}')
# Setup HTTP session
await self.llm.get_session()
# Setup Redis
# Start background tasks
await self.voice_manager.initialize()
self.loop.create_task(self._introspection_report_loop())
self.loop.create_task(self._voice_report_loop())
self.loop.create_task(self._voice_report_loop())
self.loop.create_task(self._daily_report_check_loop())
self.loop.create_task(self._gdpr_purge_loop())
# Start interviews for missing assets
for asset_id in self.asset_ids:
if str(asset_id) not in self.dispatcher.assets:
self.loop.create_task(self.dispatcher.start_interview(asset_id))
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."""
welcome_channel_id = config.get_welcome_channel(member.guild.id)
if welcome_channel_id:
channel = member.guild.get_channel(welcome_channel_id)
if channel:
embed = discord.Embed(
title="Bienvenue !",
description=f"Bienvenue {member.mention} sur {member.guild.name} !",
color=discord.Color.green()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_member_remove(self, member):
"""Handle member leave."""
goodbye_channel_id = config.get_goodbye_channel(member.guild.id)
if goodbye_channel_id:
channel = member.guild.get_channel(goodbye_channel_id)
if channel:
embed = discord.Embed(
title="Au revoir !",
description=f"{member.name} a quitté {member.guild.name}.",
color=discord.Color.red()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_message(self, message):
"""Handle incoming messages."""
# Global activity tracking (Placeholder for future privacy-compliant metrics if needed)
pass
logger.info(f"Message received from {message.author} in {message.channel}")
if message.author == self.user:
return
if isinstance(message.channel, discord.DMChannel):
processed = await self.dispatcher.handle_dm_response(message)
if processed: return
# Si ce n'est pas une réponse d'entretien, on traite comme une interaction IA normale
await self._handle_ai_interaction(message)
return
# Auto-moderation
if message.guild:
moderated = await self._handle_moderation(message)
if moderated:
return
# DEBUG: Print flow info
logger.info(f"◈ DEBUG: on_message | Author: {message.author.id} | Bot: {self.user.id if self.user else 'None'}")
if not self._should_respond(message):
logger.info(f"◈ DEBUG: _should_respond is False. Entering Heuristic Monitoring.")
# Background Heuristic Analysis (Silent monitoring)
await self._handle_heuristic_monitoring(message)
return
logger.info(f"◈ SYSTEM: Interaction directe détectée pour '{message.author.display_name}'")
# 100% Natural Language Interaction (Commands disabled)
await self._handle_ai_interaction(message)
# ==================== Cache Management ====================
def _invalidate_caches(self, guild_id: int):
"""Invalidate caches for a guild."""
self.channels_list_cache.pop(guild_id, None)
self.server_context_cache.pop(guild_id, None)
# Keep caches updated on changes
async def on_guild_channel_create(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_guild_channel_delete(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_voice_state_update(self, member, before, after):
"""Handle voice state changes for cleanup if needed."""
# On-demand only, so no automatic connection logic here.
pass
async def on_guild_channel_update(self, before, after):
self._invalidate_caches(before.guild.id)
async def on_member_join(self, member):
self._invalidate_caches(member.guild.id)
async def on_member_remove(self, member):
self._invalidate_caches(member.guild.id)
async def on_guild_role_create(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_delete(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_update(self, before, after):
self._invalidate_caches(before.guild.id)
# ==================== Heuristic Engine (Risk Assessment) ====================
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):
"""Handle auto-moderation."""
return await moderate_message(self, message)
async def _handle_heuristic_monitoring(self, message):
"""Perform background analysis without replying unless critical."""
# Filtrage rapide des messages triviaux (Triage niveau 1)
content_low = message.content.lower().strip()
if len(content_low) < 3 or content_low in self.ignored_words:
return
should_analyze = self._assess_heuristic_risk(message)
if not should_analyze:
return
logger.info(f"◈ SYSTEM: Analyse heuristique déclenchée pour '{message.author.display_name}'")
# 2. Context Injection (Channel History)
# Fetch last 10 messages for better proactive analysis
context_msgs = []
try:
async for m in message.channel.history(limit=10, before=message):
if not m.content: continue
context_msgs.append(f"{m.author.display_name}: {m.content}")
except Exception:
pass # Ignore permission errors
context_str = "\n".join(reversed(context_msgs))
# Instruction spécifique pour le mode monitoring avec contexte
monitoring_flag = (
f"\n[MODE: SURVEILLANCE HEURISTIQUE - NE RÉPONDEZ PAS SAUF SI ALERT/INSIGHT DÉTECTÉ]\n"
f"[CONTEXTE DU SALON (5 derniers messages)]:\n{context_str}\n"
f"[MESSAGE ACTUEL A ANALYSER]:\n{message.content}"
)
# We override content in _handle_ai_interaction by passing extra_context
# But _prepare_content uses message.content. We'll append this flag.
await self._handle_ai_interaction(message, extra_context=monitoring_flag, is_monitoring=True)
def _should_respond(self, message) -> bool:
"""Check if bot should respond to message."""
if message.content.startswith(self.command_prefix):
return True
if self.user in message.mentions:
logger.info("◈ DEBUG: Triggered by standard mentions list")
return True
# Robust Mention Check (Regex Fallback)
bot_id = str(self.user.id)
logger.info(f"◈ DEBUG: Checking mention for ID {bot_id} in '{message.content[:30]}...'")
if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content:
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
return True
# 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:
logger.info("◈ DEBUG: Triggered by reply")
return True
return False
# ==================== AI Interaction ====================
async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False):
"""Handle AI interaction."""
logger.info(f"AI interaction triggered (Monitoring: {is_monitoring})")
# ... (same caching logic)
if message.guild:
perms = message.author.guild_permissions
if check_is_admin(perms):
self._invalidate_caches(message.guild.id)
# Build context
author_perms = message.author.guild_permissions if message.guild else None
# Détection du rôle pour le prompt
if message.author.id in self.admin_ids:
role_name = "ADMIN"
elif message.author.id in self.asset_ids:
role_name = "ASSET"
else:
role_name = "SUJET"
permissions_info = get_permissions_info(
author_perms,
self.get_user_level(message.author.id) or 'none',
role_name=role_name
)
channels_list, server_context = build_context_for_message(
message.guild,
self.channels_list_cache,
self.server_context_cache
)
user_id = str(message.author.id)
guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
history = await build_context_for_model(user_id, max_recent=12, guild_id=guild_name) or ""
# Prepare content
content = self._prepare_content(message) + extra_context
# Build payload
payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message)
# Execute request
async with self._request_semaphore:
# Decider quel modèle utiliser
# Modèle HEAVY pour Admin/Asset ou si on lui parle directement
# Modèle FAST pour le monitoring pur (unloads the server)
is_direct = self._should_respond(message) or message.author.id in self.admin_ids
target_model = self.ollama_model if is_direct else self.ollama_model_fast
payload["model"] = target_model
async def run_call():
if self.redis_worker:
resp = await self.redis_worker.enqueue_job(payload, timeout=self.ollama_timeout + 5)
return resp.get("result", "") if resp else ""
else:
return await self.llm.call_ollama(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
)
payload["model"] = self.ollama_model
async with self._request_semaphore:
try:
analysis = await self.llm.call_ollama(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_ollama(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 = "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})"
prompt = f"{self.system_prompt}\n{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
return self.llm.build_payload(
prompt=prompt,
system_prompt=self.system_prompt,
vision_model=vision_model,
attachments=message.attachments
)
async def _process_response(self, accumulated_reply: str, message, is_monitoring=False):
"""Process LLM response and prevent JSON leakage."""
json_str = self.llm.extract_json_actions(accumulated_reply)
action_executed = False
# Nettoyer la réponse pour enlever les blocs JSON et les marqueurs internes
clean_reply = accumulated_reply
# 1. Enlever tout ce qui ressemble à du JSON de la réponse "publique"
if json_str:
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL).strip()
# 2. Nettoyage des éventuels backticks de code et résidus de JSON
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
# 3. Filtrer les marqueurs internes fréquents (Leak prevention)
markers_to_remove = [
"[Public]", "[Relevant]", "[Irrelevant]", "[Alerte]", "[Insight]",
"[CRITICITÉ", "[MENACE", "[RISQUE", "[DÉTECTION", "[ANALYSE", "[ACTION"
]
for marker in markers_to_remove:
if marker in clean_reply:
if marker.startswith("["):
# Recherche de la fin du tag ] pour les tags ouverts
start_idx = clean_reply.find(marker)
end_idx = clean_reply.find("]", start_idx)
if end_idx != -1:
clean_reply = clean_reply[:start_idx] + clean_reply[end_idx+1:]
else:
clean_reply = clean_reply.replace(marker, "")
else:
clean_reply = clean_reply.replace(marker, "")
# 4. Supprimer les résidus JSON rémanents avec un regex plus large
clean_reply = re.sub(r'\{[\s\n]*[\"\']action[\"\'].*?\}', '', clean_reply, flags=re.DOTALL)
# 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()
if json_str:
try:
actions = json.loads(json_str)
if isinstance(actions, dict): actions = [actions]
for action_data in actions:
# Traitement Alerte/Insight (Toujours en DM)
await self._handle_beta_signals(action_data, message)
# Exécution des actions système (Silence forcé en monitoring pour action: NONE)
res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring)
if res: action_executed = True
except Exception as e:
logger.error(f"Error processing actions: {e}")
# Store interaction (Even if silent, we store the input for better context next time)
user_id = str(message.author.id)
guild_id = sanitize_guild_name(message.guild.name) if message.guild else None
await add_interaction(user_id, message.channel.id, message.content, clean_reply if not is_monitoring else "", guild_id)
# En mode monitoring, on ne répond PAS au canal public sauf si l'IA l'a explicitement demandé via une action
if is_monitoring and not action_executed:
return
# Si aucune action n'a envoyé de message (comme SEND_MESSAGE ou action: NONE avec response),
# et qu'on n'est pas en monitoring, on envoie le texte nettoyé.
if not action_executed:
if clean_reply:
await self.messaging.reply_with_limit(message, format_mentions_in_text(clean_reply))
elif not is_monitoring:
# Log the raw response to understand why parsing failed
raw_snippet = accumulated_reply.strip()
logger.warning(f"◈ SYSTEM: Failed to parse action or empty response. Raw output: {raw_snippet[:200]}")
fallback_msg = "Ma réponse a été filtrée ou est invalide. Pouvez-vous reformuler ?"
await self.messaging.reply_with_limit(message, fallback_msg)
async def _handle_beta_signals(self, action_data, message):
"""Handle Alert (Relevant) and Insight (Irrelevant) signals with full context."""
insight = action_data.get('insight')
alert = action_data.get('alert')
importance = action_data.get('importance', 0)
# Préparation du bloc de données structurées
context_data = {
"author_mention": message.author.mention,
"author_name": message.author.display_name,
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
"content": f"*{message.content[:250]}{'...' if len(message.content) > 250 else ''}*",
"jump_url": message.jump_url if message.jump_url else None
}
# 1. ALERT -> L'ADMIN (Relevant) - Triage Critique
if alert and self.admin_ids:
# Formatage de l'alerte pour l'Admin (markdown propre)
admin_trigger_text = (
f"{alert}\n\n"
f"**◈ CONTEXTE DÉTECTION ◈**\n"
f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n"
f"- **Salon** : {context_data['channel_mention']}\n"
+ (f"- **Lien direct** : [Accéder au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale Directe")
)
for admin_id in self.admin_ids:
admin = self.get_user(admin_id) or await self.fetch_user(admin_id)
if admin:
await self.messaging.send_embed(
admin,
f"ALERTE RELEVANT (Priorité {importance}/10)",
admin_trigger_text,
color=discord.Color.red()
)
# 2. INSIGHT -> STAFF (Irrelevant) - Triage Opérationnel
if insight and self.asset_ids:
if alert:
logger.warning(f"◈ ALERT RELEVANT: {alert}")
else:
if importance > 7:
logger.info(f"◈ INSIGHT IRRELEVANT (High Importance {importance}): {insight[:50]}...")
# Utilisation du Dispatcher avec données structurées
await self.dispatcher.dispatch_insight(insight, importance, message.guild, context_data)
async def _execute_actions(self, action_data, message, final_content, is_monitoring=False) -> bool:
"""Execute JSON actions and provide feedback."""
action_executed = False
async def process_item(item):
nonlocal action_executed
if not isinstance(item, dict) or 'action' not in item:
return
action = item['action']
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)
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
# ==================== 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:
"""Return runtime metrics."""
merged = {**self.metrics}
try:
from ia.memoire import get_metrics as _m
mem = _m()
merged["memory"] = mem
except Exception:
merged["memory"] = {}
if self.redis_worker:
merged["queue_pending"] = len(self.redis_worker._pending_jobs)
return merged
async def close(self):
"""Clean shutdown."""
try:
await flush_all()
except Exception:
logger.exception("Error flushing memories")
await self.llm.close_session()
if self.redis_worker:
await self.redis_worker.close()
await super().close()