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)
2026-03-22 14:43:53 +01:00
if message . guild and hasattr ( message . author , ' guild_permissions ' ) :
2026-02-06 22:23:20 +01:00
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 "
2026-03-22 14:43:53 +01:00
from ia . memoire import get_structured_history
resume , structured_history = await get_structured_history ( user_id , max_recent = 12 , guild_id = guild_name )
2026-02-06 22:23:20 +01:00
# Prepare content
content = self . _prepare_content ( message ) + extra_context
# Build payload
2026-03-22 14:43:53 +01:00
payload = self . _build_payload (
permissions_info ,
server_context ,
channels_list ,
resume ,
structured_history ,
content ,
message
)
2026-02-06 22:23:20 +01:00
# 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 } \n Nom d ' utilisateur : { mock_author . display_name } { server_context } { channels_list } \n { history } \n \n User: { 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 } ' )
2026-03-22 14:43:53 +01:00
# Remove bot mention (Désactivé pour que le LLM voit qu'il est mentionné)
# content = content.lstrip(f'<@{self.user.id}>').lstrip(f'<@!{self.user.id}>').strip()
2026-02-06 22:23:20 +01:00
# 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 ,
2026-03-22 14:43:53 +01:00
resume : str ,
structured_history : list ,
2026-02-06 22:23:20 +01:00
content : str ,
2026-03-22 14:43:53 +01:00
message ,
is_monitoring : bool = False
2026-02-06 22:23:20 +01:00
) - > dict :
2026-03-22 14:43:53 +01:00
""" Build LLM payload with interaction mode context. """
2026-02-06 22:23:20 +01:00
user_name_info = f " Nom d ' utilisateur : { message . author . display_name } (ID: { message . author . id } ) "
2026-03-22 14:43:53 +01:00
# Indicateur de mode pour éviter que l'IA ne bégaye sur sa légitimité à répondre
mode_indicator = " [MODE: RÉPONSE DIRECTE (Vous avez été interpellé par l ' utilisateur)] "
if is_monitoring :
mode_indicator = " [MODE: SURVEILLANCE HEURISTIQUE (N ' intervenez que si nécessaire via ALERT/INSIGHT)] "
import time
current_time = time . strftime ( ' % H: % M: % S le %d / % m/ % Y ' )
time_info = f " [Heure actuelle du serveur : { current_time } ] "
prompt_system = f " { self . system_prompt } \n { time_info } \n { permissions_info } \n { mode_indicator } \n { user_name_info } { server_context } { channels_list } "
if resume :
prompt_system + = f " \n Contexte global (mémoire) : \n { resume } "
2026-02-06 22:23:20 +01:00
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 (
2026-03-22 14:43:53 +01:00
prompt = content ,
system_prompt = prompt_system ,
structured_history = structured_history ,
2026-02-06 22:23:20 +01:00
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 ( )
2026-03-22 14:43:53 +01:00
# 3. Supprimer les balises de réflexion (Thinking/Thought) et leur contenu
clean_reply = re . sub ( r ' <thought>.*?</thought> ' , ' ' , clean_reply , flags = re . DOTALL | re . IGNORECASE )
clean_reply = re . sub ( r ' <reflexion>.*?</reflexion> ' , ' ' , clean_reply , flags = re . DOTALL | re . IGNORECASE )
clean_reply = re . sub ( r ' \ [THOUGHT \ ].*? \ [/THOUGHT \ ] ' , ' ' , clean_reply , flags = re . DOTALL | re . IGNORECASE )
# 4. Nettoyage des tokens spéciaux de modèles (ChatML/DeepSeek etc)
tokens_to_remove = [
" <|end_of_sentence|> " , " <|endoftext|> " , " <|end|> " , " <|start|> " ,
" <|assistant|> " , " <|user|> " , " <|system|> " , " <|channel|> " ,
" <|constrain|> " , " <|message|> " , " <|thought|> "
]
for token in tokens_to_remove :
clean_reply = clean_reply . replace ( token , " " )
# 5. Filtrer les marqueurs internes fréquents (Leak prevention)
2026-02-06 22:23:20 +01:00
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 , " " )
2026-03-22 14:43:53 +01:00
# 6. Supprimer les résidus JSON rémanents avec un regex plus large
2026-02-06 22:23:20 +01:00
clean_reply = re . sub ( r ' \ { [ \ s \ n]*[ \ " \' ]action[ \ " \' ].*? \ } ' , ' ' , clean_reply , flags = re . DOTALL )
2026-03-22 14:43:53 +01:00
# 7. Supprimer les lignes vides en trop et les espaces multiples
2026-02-06 22:23:20 +01:00
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 )
2026-03-22 14:43:53 +01:00
guild_id = sanitize_guild_name ( message . guild . name ) if message . guild else " Direct "
2026-02-06 22:23:20 +01:00
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 ' :
2026-03-22 14:43:53 +01:00
action_executed = True # Bloque le fallback text même si la réponse est vide
2026-02-06 22:23:20 +01:00
resp = item . get ( ' response ' , " " ) . strip ( )
if resp and not is_monitoring :
await self . messaging . reply_with_limit ( message , format_mentions_in_text ( resp ) )
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 ( )