1303 lines
59 KiB
Python
1303 lines
59 KiB
Python
"""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()
|
|
|