fix: gpt-oss:20b ollama, streaming, tableaux JSON, recherche flexible salons/categories
This commit is contained in:
parent
14985f6dbb
commit
189d56026b
21 changed files with 2824 additions and 491 deletions
158
core/action_router.py
Normal file
158
core/action_router.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import discord
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
class ActionRouter:
|
||||
"""Routs generated AI semantic actions to the physical Discord moderative or administrative commands."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self._module_cache = {}
|
||||
|
||||
async def execute_action(self, action: str, params: Dict, message: discord.Message) -> (bool, str):
|
||||
"""Execute an action from LLM response."""
|
||||
|
||||
# Normalize action name: replace spaces with underscores, uppercase
|
||||
action = action.strip().upper().replace(' ', '_')
|
||||
|
||||
# Merge nested params if LLM created a sub-object by mistake
|
||||
for k in ['parametre', 'parametres', 'params']:
|
||||
if k in params and isinstance(params[k], dict):
|
||||
params.update(params[k])
|
||||
|
||||
# --- INSIGHT HOOK (LLM generated task for Staff) ---
|
||||
if action == 'INSIGHT':
|
||||
content = params.get('message') or params.get('content') or params.get('insight') or "Pas de contenu"
|
||||
|
||||
if 'conflit' in content.lower() or 'tension' in content.lower():
|
||||
logger.debug("Insight (Tension) logged.")
|
||||
|
||||
context_data = {
|
||||
"author_mention": message.author.mention,
|
||||
"author_name": message.author.display_name,
|
||||
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
|
||||
"content": message.content[:200]
|
||||
}
|
||||
|
||||
await self.bot.dispatcher.dispatch_insight(
|
||||
insight=content,
|
||||
importance=params.get('importance', 2),
|
||||
guild=message.guild,
|
||||
context_data=context_data
|
||||
)
|
||||
return True, None
|
||||
# -----------------------------
|
||||
|
||||
# --- ALERT HOOK (LLM generated alert for Admin) ---
|
||||
if action == 'ALERT':
|
||||
content = params.get('message') or params.get('content') or params.get('alert') or "Alerte vide"
|
||||
|
||||
embed = discord.Embed(title="🚨 ALERTE DIRECTE (IA)", description=content, color=discord.Color.red())
|
||||
embed.set_footer(text=f"Source: {message.author.display_name} | Salon: {message.channel}")
|
||||
|
||||
for admin_id in self.bot.admin_ids:
|
||||
u = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
|
||||
if u: await u.send(embed=embed)
|
||||
|
||||
return True, None
|
||||
# -----------------------------
|
||||
|
||||
# --- VOICE ACTIONS ---
|
||||
if action == 'JOIN_VOICE':
|
||||
voice_state = getattr(message.author, 'voice', None)
|
||||
|
||||
if not voice_state and self.bot.guild_id:
|
||||
guild = self.bot.get_guild(self.bot.guild_id)
|
||||
if guild:
|
||||
member = guild.get_member(message.author.id) or await guild.fetch_member(message.author.id)
|
||||
if member:
|
||||
voice_state = member.voice
|
||||
|
||||
if voice_state and voice_state.channel:
|
||||
await self.bot.voice_manager.join_channel(voice_state.channel)
|
||||
return True, f"Connecté à {voice_state.channel.name}"
|
||||
else:
|
||||
return False, "Vous n'êtes pas dans un salon vocal identifiable par le système."
|
||||
|
||||
if action == 'LEAVE_VOICE':
|
||||
await self.bot.voice_manager.leave_current()
|
||||
return True, None
|
||||
|
||||
# --- PRIVACY ACTIONS ---
|
||||
if action == 'FORGET_USER':
|
||||
from brain.memoire import delete_user_memory
|
||||
delete_user_memory(message.author.id)
|
||||
return True, None
|
||||
|
||||
# --- PERMISSION ENFORCEMENT (ADMIN ONLY) ---
|
||||
# Publicly accessible actions
|
||||
PUBLIC_ACTIONS = {'PING', 'HELP', 'STATUS', 'JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER'}
|
||||
|
||||
if action not in PUBLIC_ACTIONS:
|
||||
is_admin_id = message.author.id in self.bot.admin_ids
|
||||
has_admin_perms = message.author.guild_permissions.administrator if hasattr(message.author, 'guild_permissions') else False
|
||||
|
||||
if not (is_admin_id or has_admin_perms):
|
||||
logger.warning(f"◈ PERMISSION DENIED: {message.author.display_name} a tenté l'action '{action}'")
|
||||
return False, "Cette opération est réservée aux administrateurs."
|
||||
|
||||
action_map = {
|
||||
'KICK': 'commandes.security.kick',
|
||||
'BAN': 'commandes.security.ban',
|
||||
'UNBAN': 'commandes.security.unban',
|
||||
'UNMUTE': 'commandes.security.unmute',
|
||||
'MUTE': 'commandes.security.mute',
|
||||
'TIMEOUT': 'commandes.security.timeout',
|
||||
'PURGE': 'commandes.security.purge',
|
||||
'WARN': 'commandes.security.warn',
|
||||
'LIST_WARNINGS': 'commandes.security.list_warnings',
|
||||
'CREATE_CHANNEL': 'commandes.salons.creer',
|
||||
'DELETE_CHANNEL': 'commandes.salons.supprimer',
|
||||
'MODIFY_CHANNEL': 'commandes.salons.modifier',
|
||||
'RENAME_CHANNEL': 'commandes.salons.renommer',
|
||||
'MOVE_CHANNEL': 'commandes.salons.deplacer',
|
||||
'CREATE_ROLE': 'commandes.roles.creer',
|
||||
'DELETE_ROLE': 'commandes.roles.supprimer',
|
||||
'MODIFY_ROLE': 'commandes.roles.modifier',
|
||||
'RENAME_ROLE': 'commandes.roles.renommer',
|
||||
'MOVE_ROLE': 'commandes.roles.deplacer',
|
||||
'ADD_ROLE_TO_USER': 'commandes.roles.add_role',
|
||||
'REMOVE_ROLE_FROM_USER': 'commandes.roles.remove_role',
|
||||
'CREATE_CATEGORY': 'commandes.categories.creer',
|
||||
'DELETE_CATEGORY': 'commandes.categories.supprimer',
|
||||
'MODIFY_CATEGORY': 'commandes.categories.modifier',
|
||||
'RENAME_CATEGORY': 'commandes.categories.renommer',
|
||||
'MOVE_CATEGORY': 'commandes.categories.deplacer',
|
||||
'PING': 'commandes.autres.ping',
|
||||
'SET_WELCOME_CHANNEL': 'commandes.autres.setwelcomechannel',
|
||||
'SET_GOODBYE_CHANNEL': 'commandes.autres.setgoodbyechannel',
|
||||
'SET_MUTE_ROLE': 'commandes.autres.setmuterole',
|
||||
'SEND_MESSAGE': 'commandes.autres.send_message',
|
||||
'HELP': 'commandes.autres.help',
|
||||
'STATUS': 'commandes.autres.status',
|
||||
}
|
||||
|
||||
module_name = action_map.get(action)
|
||||
if module_name:
|
||||
logger.debug(f"Importing module: {module_name}")
|
||||
try:
|
||||
module = self._module_cache.get(module_name)
|
||||
if module is None:
|
||||
module = __import__(module_name, fromlist=['execute'])
|
||||
self._module_cache[module_name] = module
|
||||
|
||||
logger.debug(f"Module {module_name} imported successfully")
|
||||
await module.execute(self.bot, params, message)
|
||||
logger.info(f"Action '{action}' executed successfully")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Error during action execution '{action}': {error_msg}")
|
||||
return False, error_msg
|
||||
else:
|
||||
error_msg = f"Unknown action: {action}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
914
core/bot.py
Normal file
914
core/bot.py
Normal file
|
|
@ -0,0 +1,914 @@
|
|||
"""Main Superviseur Bot class - refactored for better organization."""
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .utils import format_mentions_in_text, sanitize_guild_name
|
||||
from .permissions import WhitelistManager, check_is_admin, get_permissions_info
|
||||
from .messaging import MessagingManager
|
||||
from .context import build_context_for_message
|
||||
from .llm import LLMManager
|
||||
from .redis_worker import RedisWorker
|
||||
from .commands import setup_commands
|
||||
from .voice import VoiceManager
|
||||
from .dispatcher import AssetDispatcher
|
||||
from .tasks import TaskManager
|
||||
from .action_router import ActionRouter
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
from brain.memoire import add_interaction, build_context_for_model, flush_all, MEMORY_DIR
|
||||
from brain.moderation import init_db, log_infraction, scan_message_toxicity
|
||||
from commandes.autres.config import config
|
||||
from commandes.autres.logger import action_logger
|
||||
|
||||
class MockMessage:
|
||||
"""Mock discord.Message for voice-triggered interactions."""
|
||||
def __init__(self, author, channel, content, guild=None):
|
||||
self.author = author
|
||||
self.channel = channel
|
||||
self.content = content
|
||||
self.guild = guild or (channel.guild if hasattr(channel, 'guild') else None)
|
||||
self.attachments = []
|
||||
self.mentions = []
|
||||
self.channel_mentions = []
|
||||
self.role_mentions = []
|
||||
self.jump_url = ""
|
||||
|
||||
async def reply(self, content, **kwargs):
|
||||
"""Mock reply - sends a message to the channel/user instead."""
|
||||
# For vocal interaction, we don't need a prefix in DM as it's a private chat
|
||||
is_dm = isinstance(self.channel, (discord.DMChannel, discord.User, discord.Member))
|
||||
prefix = "" if is_dm else f"**{self.author.display_name}** (Vocal) : "
|
||||
return await self.channel.send(f"{prefix}{content}", **kwargs)
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
|
||||
class Superviseur(commands.Bot):
|
||||
"""Main bot class for Superviseur Discord bot."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
guild_id: int,
|
||||
llama_model,
|
||||
system_prompt: str,
|
||||
command_prefix: str,
|
||||
intents: discord.Intents,
|
||||
temperature: float = 0.4,
|
||||
model_loaded: bool = True
|
||||
):
|
||||
super().__init__(command_prefix=command_prefix, intents=intents)
|
||||
|
||||
# Configuration
|
||||
self.guild_id = guild_id
|
||||
self.llama_model = llama_model
|
||||
self.system_prompt = system_prompt
|
||||
self.temperature = temperature
|
||||
self.model_loaded = model_loaded
|
||||
|
||||
# Identity
|
||||
self.system_name = "Bêta"
|
||||
admins_env = os.getenv("ADMIN_IDS", "")
|
||||
self.admin_ids = [int(i.strip()) for i in admins_env.split(",") if i.strip().isdigit()]
|
||||
self.introspection_channel_id = int(os.getenv("INTROSPECTION_CHANNEL_ID", 0))
|
||||
|
||||
# Assets (Staff)
|
||||
assets_env = os.getenv("PRIMARY_ASSETS_IDS", "")
|
||||
self.asset_ids = [int(i.strip()) for i in assets_env.split(",") if i.strip().isdigit()]
|
||||
|
||||
# Spam Prevention
|
||||
self.insight_cooldowns = {} # {user_id: timestamp}
|
||||
self.insight_cooldown_duration = 1800 # 30 minutes
|
||||
self.insight_threshold = 5
|
||||
self.ignored_words = {"salut", "cc", "hello", "ça va", "ca va", "bjr", "slt", "yo", "re", "ok"}
|
||||
self.monitoring_cooldowns = {}
|
||||
|
||||
# Caches
|
||||
self.channels_list_cache = {}
|
||||
self.server_context_cache = {}
|
||||
self._module_cache: Dict[str, object] = {}
|
||||
|
||||
# HTTP session
|
||||
self.http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Concurrency control
|
||||
self.max_concurrent_requests = int(os.environ.get("SUPERVISEUR_MAX_CONCURRENT", 4))
|
||||
self._request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
||||
|
||||
# Metrics
|
||||
self.metrics = {
|
||||
"total_llm_requests": 0,
|
||||
"total_llm_success": 0,
|
||||
"total_llm_errors": 0,
|
||||
"total_llm_time": 0.0,
|
||||
}
|
||||
|
||||
# Redis
|
||||
self.aioredis = None
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
self.aioredis = aioredis
|
||||
except Exception:
|
||||
pass
|
||||
self.redis_worker: Optional[RedisWorker] = None
|
||||
|
||||
# Whitelist
|
||||
self.whitelist_manager = WhitelistManager()
|
||||
|
||||
# Messaging
|
||||
self.messaging = MessagingManager(self)
|
||||
|
||||
# Disable command prefix for natural language focus
|
||||
# command_prefix is set by the constructor argument (e.g. "!")
|
||||
|
||||
# Voice Management
|
||||
self.voice_manager = VoiceManager(self)
|
||||
|
||||
# LLM
|
||||
self.llm = llama_model
|
||||
|
||||
# Dispatcher & Sub-managers
|
||||
self.dispatcher = AssetDispatcher(self)
|
||||
self.task_manager = TaskManager(self)
|
||||
self.action_router = ActionRouter(self)
|
||||
|
||||
# Init SQLite Moderation DB
|
||||
init_db()
|
||||
|
||||
# Logging avec fuseau horaire local (Europe/Paris)
|
||||
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
|
||||
|
||||
def local_time_converter(*args):
|
||||
return datetime.datetime.now(self.timezone).timetuple()
|
||||
|
||||
logging.Formatter.converter = local_time_converter
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, # Augmenté à INFO par défaut pour le root
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
# Sourdine sur les logs Discord trop bavards (RTCP, Gateway)
|
||||
logging.getLogger('discord').setLevel(logging.WARNING)
|
||||
logging.getLogger('discord.voice_state').setLevel(logging.WARNING)
|
||||
logging.getLogger('discord.gateway').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
logging.getLogger('huggingface_hub').setLevel(logging.WARNING)
|
||||
try:
|
||||
logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING)
|
||||
except: pass
|
||||
|
||||
# ==================== Utility Methods ====================
|
||||
|
||||
async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()):
|
||||
"""Internal telemetry log - bypassing AI to preserve resources."""
|
||||
if not self.introspection_channel_id: return
|
||||
|
||||
channel = self.get_channel(self.introspection_channel_id) or await self.fetch_channel(self.introspection_channel_id)
|
||||
if channel:
|
||||
await self.messaging.send_embed(
|
||||
channel,
|
||||
f"INTROSPECTION: {title}",
|
||||
description,
|
||||
color=color
|
||||
)
|
||||
|
||||
def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]:
|
||||
"""Get user's whitelist level."""
|
||||
return self.whitelist_manager.get_user_level(user_id, user_name)
|
||||
|
||||
def has_whitelist_permission(
|
||||
self,
|
||||
user_id: int,
|
||||
required_level: str,
|
||||
user_name: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Check whitelist permission."""
|
||||
return self.whitelist_manager.has_permission(user_id, required_level, user_name)
|
||||
|
||||
# ==================== Event Handlers ====================
|
||||
|
||||
async def on_ready(self):
|
||||
"""Called when bot is ready."""
|
||||
print(f'Logged in as {self.user}')
|
||||
|
||||
# Register commands
|
||||
await setup_commands(self)
|
||||
|
||||
# Create memory directories
|
||||
import os
|
||||
for guild in self.guilds:
|
||||
sanitized_name = sanitize_guild_name(guild.name)
|
||||
guild_dir = os.path.join(MEMORY_DIR, sanitized_name)
|
||||
os.makedirs(guild_dir, exist_ok=True)
|
||||
print(f'Memory directory created for guild {guild.name}: {guild_dir}')
|
||||
|
||||
# Setup HTTP session (not needed for llama-cpp-python, but kept for compatibility)
|
||||
pass
|
||||
|
||||
# Setup Redis
|
||||
# Start background tasks
|
||||
await self.voice_manager.initialize()
|
||||
self.task_manager.start_all()
|
||||
|
||||
|
||||
async def on_member_join(self, member):
|
||||
"""Handle member join + cache invalidation."""
|
||||
self._invalidate_caches(member.guild.id)
|
||||
welcome_channel_id = config.get_welcome_channel(member.guild.id)
|
||||
if welcome_channel_id:
|
||||
channel = member.guild.get_channel(welcome_channel_id)
|
||||
if channel:
|
||||
embed = discord.Embed(
|
||||
title="Bienvenue !",
|
||||
description=f"Bienvenue {member.mention} sur {member.guild.name} !",
|
||||
color=discord.Color.green()
|
||||
)
|
||||
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def on_member_remove(self, member):
|
||||
"""Handle member leave + cache invalidation."""
|
||||
self._invalidate_caches(member.guild.id)
|
||||
goodbye_channel_id = config.get_goodbye_channel(member.guild.id)
|
||||
if goodbye_channel_id:
|
||||
channel = member.guild.get_channel(goodbye_channel_id)
|
||||
if channel:
|
||||
embed = discord.Embed(
|
||||
title="Au revoir !",
|
||||
description=f"{member.name} a quitté {member.guild.name}.",
|
||||
color=discord.Color.red()
|
||||
)
|
||||
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def on_message(self, message):
|
||||
"""Handle incoming messages."""
|
||||
if message.author == self.user:
|
||||
return
|
||||
|
||||
logger.debug(f"Message received from {message.author} in {message.channel}")
|
||||
|
||||
# 1. Silent Moderation Scan (Background)
|
||||
# On ne bloque pas le bot pour l'analyse, mais on la lance pour chaque message
|
||||
asyncio.create_task(self._handle_silent_scan(message))
|
||||
|
||||
# 2. DM Treatment
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
processed = await self.dispatcher.handle_dm_response(message)
|
||||
if processed: return
|
||||
await self._handle_ai_interaction(message)
|
||||
return
|
||||
|
||||
# 3. Mention/Ping Check
|
||||
if not self._should_respond(message):
|
||||
return
|
||||
|
||||
logger.info(f"◈ SYSTEM: Interaction directe détectée pour '{message.author.display_name}'")
|
||||
# Direct interaction (AI Response)
|
||||
await self._handle_ai_interaction(message)
|
||||
|
||||
# ==================== Cache Management ====================
|
||||
|
||||
def _invalidate_caches(self, guild_id: int):
|
||||
"""Invalidate caches for a guild."""
|
||||
self.channels_list_cache.pop(guild_id, None)
|
||||
self.server_context_cache.pop(guild_id, None)
|
||||
|
||||
# Keep caches updated on changes
|
||||
async def on_guild_channel_create(self, channel):
|
||||
self._invalidate_caches(channel.guild.id)
|
||||
|
||||
async def on_guild_channel_delete(self, channel):
|
||||
self._invalidate_caches(channel.guild.id)
|
||||
|
||||
async def on_voice_state_update(self, member, before, after):
|
||||
"""Handle voice state changes for cleanup if needed."""
|
||||
# On-demand only, so no automatic connection logic here.
|
||||
pass
|
||||
|
||||
async def on_guild_channel_update(self, before, after):
|
||||
self._invalidate_caches(before.guild.id)
|
||||
|
||||
async def on_guild_role_create(self, role):
|
||||
self._invalidate_caches(role.guild.id)
|
||||
|
||||
async def on_guild_role_delete(self, role):
|
||||
self._invalidate_caches(role.guild.id)
|
||||
|
||||
async def on_guild_role_update(self, before, after):
|
||||
self._invalidate_caches(before.guild.id)
|
||||
|
||||
# ==================== Heuristic Engine (Risk Assessment) ====================
|
||||
|
||||
# ==================== Moderation ====================
|
||||
|
||||
# ==================== Moderation Silencieuse ====================
|
||||
|
||||
async def _handle_silent_scan(self, message):
|
||||
"""Analyze message toxicity in the background and log to SQLite."""
|
||||
if not message.content or len(message.content.strip()) < 3:
|
||||
return
|
||||
|
||||
try:
|
||||
# On ne scanne que si un modèle est chargé
|
||||
if not self.model_loaded:
|
||||
return
|
||||
|
||||
res = await scan_message_toxicity(self.llm, message.content)
|
||||
if res and res.get('score', 0) > 0.4:
|
||||
score = res['score']
|
||||
reason = res.get('reason', 'N/A')
|
||||
logger.info(f"◈ MODERATION: Infraction détectée ({score}/1.0) pour {message.author.display_name}: {reason}")
|
||||
|
||||
# Enregistrement en DB
|
||||
log_infraction(
|
||||
user_id=str(message.author.id),
|
||||
username=message.author.display_name,
|
||||
guild_id=str(message.guild.id) if message.guild else "DM",
|
||||
content=message.content,
|
||||
score=score,
|
||||
reason=reason
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du scan silencieux: {e}")
|
||||
|
||||
def _should_respond(self, message) -> bool:
|
||||
"""Check if bot should respond to message."""
|
||||
if message.content.startswith(self.command_prefix):
|
||||
return True
|
||||
if self.user in message.mentions:
|
||||
logger.info("◈ DEBUG: Triggered by standard mentions list")
|
||||
return True
|
||||
|
||||
# Robust Mention Check (Regex Fallback)
|
||||
bot_id = str(self.user.id)
|
||||
logger.info(f"◈ DEBUG: Checking mention for ID {bot_id} in '{message.content[:30]}...'")
|
||||
|
||||
if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content:
|
||||
logger.info("◈ DEBUG: Triggered by string match (Regex Fallback)")
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
# ==================== AI Interaction ====================
|
||||
|
||||
async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False):
|
||||
"""Handle AI interaction."""
|
||||
logger.info(f"AI interaction triggered (Monitoring: {is_monitoring})")
|
||||
|
||||
# ... (same caching logic)
|
||||
if message.guild:
|
||||
perms = message.author.guild_permissions
|
||||
if check_is_admin(perms):
|
||||
self._invalidate_caches(message.guild.id)
|
||||
|
||||
# Build context
|
||||
author_perms = message.author.guild_permissions if message.guild else None
|
||||
|
||||
# Détection du rôle pour le prompt
|
||||
if message.author.id in self.admin_ids:
|
||||
role_name = "ADMIN"
|
||||
elif message.author.id in self.asset_ids:
|
||||
role_name = "ASSET"
|
||||
else:
|
||||
role_name = "SUJET"
|
||||
|
||||
permissions_info = get_permissions_info(
|
||||
author_perms,
|
||||
self.get_user_level(message.author.id) or 'none',
|
||||
role_name=role_name
|
||||
)
|
||||
channels_list, server_context = build_context_for_message(
|
||||
message.guild,
|
||||
self.channels_list_cache,
|
||||
self.server_context_cache
|
||||
)
|
||||
|
||||
user_id = str(message.author.id)
|
||||
guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
|
||||
# Limite stricte de l'historique pour éviter la saturation CPU
|
||||
history = await build_context_for_model(user_id, max_recent=8, guild_id=guild_name) or ""
|
||||
|
||||
# Prepare content
|
||||
content = self._prepare_content(message) + extra_context
|
||||
|
||||
# Build payload
|
||||
payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message)
|
||||
|
||||
# Execute request
|
||||
async with self._request_semaphore:
|
||||
# Decider quel modèle utiliser
|
||||
# Modèle HEAVY pour Admin/Asset ou si on lui parle directement
|
||||
# Modèle FAST pour le monitoring pur (unloads the server)
|
||||
is_direct = self._should_respond(message) or message.author.id in self.admin_ids
|
||||
# For llama-cpp-python, we don't need to specify different models in payload
|
||||
# The model is already set in the LLMManager initialization
|
||||
|
||||
async def run_call():
|
||||
if self.redis_worker:
|
||||
resp = await self.redis_worker.enqueue_job(payload, timeout=605) # 10 minutes + 5 seconds
|
||||
return resp.get("result", "") if resp else ""
|
||||
else:
|
||||
return await self.llm.call_llama(payload)
|
||||
|
||||
try:
|
||||
self.metrics["total_llm_requests"] += 1
|
||||
start = time.perf_counter()
|
||||
|
||||
if is_monitoring:
|
||||
# Pas d'indicateur de frappe en mode surveillance
|
||||
accumulated_reply = await run_call()
|
||||
else:
|
||||
async with message.channel.typing():
|
||||
accumulated_reply = await run_call()
|
||||
|
||||
# --- ReAct Loop for Memory (READ_LOGS) ---
|
||||
if "READ_LOGS" in accumulated_reply:
|
||||
logger.info("◈ SYSTEM: Memory access requested (READ_LOGS). Fetching logs and re-prompting...")
|
||||
logs_context = self.get_recent_logs()
|
||||
|
||||
# Update payload with logs
|
||||
extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}"
|
||||
payload["prompt"] += extra_memory_context
|
||||
|
||||
# Re-run the call with memory
|
||||
if is_monitoring:
|
||||
accumulated_reply = await run_call()
|
||||
else:
|
||||
async with message.channel.typing():
|
||||
accumulated_reply = await run_call()
|
||||
# ----------------------------------------
|
||||
|
||||
await self._process_response(accumulated_reply, message, is_monitoring=is_monitoring)
|
||||
|
||||
dur = time.perf_counter() - start
|
||||
self.metrics["total_llm_success"] += 1
|
||||
self.metrics["total_llm_time"] += dur
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
dur = time.perf_counter() - start
|
||||
self.metrics["total_llm_errors"] += 1
|
||||
self.metrics["total_llm_time"] += dur
|
||||
except:
|
||||
pass
|
||||
logger.error(f"LLM error: {e}")
|
||||
if not is_monitoring:
|
||||
await self.messaging.reply_with_limit(message, f"Erreur: {e}")
|
||||
|
||||
async def handle_voice_interaction(self, user_name: str, user_id: int, content: str, channel):
|
||||
"""Bridge for real-time voice-to-text interaction."""
|
||||
try:
|
||||
# Create a mock objects/context for internal processing
|
||||
mock_author = self.get_user(user_id) or await self.fetch_user(user_id)
|
||||
if not mock_author:
|
||||
logger.error(f"◈ SYSTEM: Voice trigger FAIL - User {user_id} ({user_name}) introuvable.")
|
||||
return
|
||||
|
||||
logger.info(f"◈ SYSTEM: Voice trigger processing for {user_name}: '{content}'")
|
||||
|
||||
# Role detection
|
||||
role_name = "ADMIN" if user_id in self.admin_ids else "ASSET" if user_id in self.asset_ids else "SUJET"
|
||||
|
||||
# REFINEMENT: Only respond to Admins and Assets (Staff)
|
||||
if role_name == "SUJET":
|
||||
logger.info(f"◈ SYSTEM: Voice trigger ignored for SUJET {user_name}")
|
||||
return
|
||||
|
||||
# Specialized context for vocal origin - FORCE PLAIN TEXT DIALOGUE
|
||||
vocal_context = (
|
||||
f"\n[MODE: DIALOGUE VOCAL DIRECT - PRIORITÉ ABSOLUE]\n"
|
||||
f"L'interlocuteur {user_name} ({role_name}) vous parle à l'oral.\n"
|
||||
f"RÈGLES CRITIQUES :\n"
|
||||
f"1. RÉPONDEZ DIRECTEMENT en langage naturel. NE PAS UTILISER DE FORMAT JSON.\n"
|
||||
f"2. INTERDICTION DE GÉNÉRER DES TÂCHES, INSIGHTS OU ALERTES.\n"
|
||||
f"3. Votre réponse sera envoyée en MESSAGE PRIVÉ à cet utilisateur.\n"
|
||||
f"4. Soyez concis, clinique et engagez la conversation.\n"
|
||||
f"REMARQUE : Ignorez la règle 'JSON UNIQUEMENT' pour cette transmission.\n"
|
||||
)
|
||||
|
||||
# Build context
|
||||
author_perms = mock_author.guild_permissions if hasattr(mock_author, 'guild') else None
|
||||
permissions_info = get_permissions_info(author_perms, self.get_user_level(user_id) or 'none', role_name=role_name)
|
||||
|
||||
# We don't need channel guild if we respond in DM
|
||||
channels_list, server_context = build_context_for_message(None, self.channels_list_cache, self.server_context_cache)
|
||||
|
||||
# Prepare history (DM history is better here)
|
||||
history = await build_context_for_model(str(user_id), max_recent=12) or ""
|
||||
|
||||
# Create MockMessage targeting the USER (for DM response)
|
||||
mock_msg = MockMessage(author=mock_author, channel=mock_author, content=content)
|
||||
|
||||
# Prepare a specialized system prompt for vocal mode (no JSON requirement)
|
||||
vocal_system_prompt = self.system_prompt.split("FORMAT DE RÉPONSE STRICT")[0]
|
||||
vocal_system_prompt += (
|
||||
"\n[PROTOCOLE VOCAL ACTIF]\n"
|
||||
"RÉPONDEZ EXCLUSIVEMENT EN TEXTE PUR. INTERDICTION TOTALE DE GÉNÉRER DU JSON OU DES MARQUEURS.\n"
|
||||
"Soyez concis, clinique et engagez une conversation fluide en Message Privé avec l'utilisateur."
|
||||
)
|
||||
|
||||
# Prepare payload with specialized prompt
|
||||
payload = self.llm.build_payload(
|
||||
prompt=f"{permissions_info}\nNom d'utilisateur : {mock_author.display_name}{server_context}{channels_list}\n{history}\n\nUser: {content}",
|
||||
system_prompt=vocal_system_prompt,
|
||||
vision_model=False
|
||||
)
|
||||
|
||||
async with self._request_semaphore:
|
||||
try:
|
||||
analysis = await self.llm.call_llama(payload)
|
||||
if not analysis:
|
||||
logger.warning(f"◈ SYSTEM: LLM returned empty analysis for voice trigger.")
|
||||
return
|
||||
|
||||
# --- ReAct Loop for Memory (READ_LOGS) in Voice Mode ---
|
||||
if "READ_LOGS" in analysis:
|
||||
logger.info("◈ SYSTEM: Voice memory access requested (READ_LOGS). Fetching logs...")
|
||||
logs_context = self.get_recent_logs()
|
||||
|
||||
extra_memory_context = f"\n\n[SYSTÈME: Voici les logs demandés. Utilisez-les pour répondre.]\n{logs_context}"
|
||||
payload["prompt"] += extra_memory_context
|
||||
|
||||
analysis = await self.llm.call_llama(payload)
|
||||
# ----------------------------------------------------
|
||||
|
||||
logger.debug(f"◈ DEBUG: Raw vocal response: {analysis[:100]}...")
|
||||
|
||||
# Use standard _process_response with mock_msg
|
||||
await self._process_response(analysis, mock_msg)
|
||||
logger.info(f"◈ SYSTEM: Voice trigger response processed for {user_name}.")
|
||||
except Exception as e:
|
||||
logger.error(f"◈ SYSTEM: Error during voice trigger AI processing: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"◈ SYSTEM: Error in handle_voice_interaction bridge: {e}")
|
||||
|
||||
def _prepare_content(self, message) -> str:
|
||||
"""Prepare message content for LLM."""
|
||||
if not message: return ""
|
||||
content = message.content
|
||||
|
||||
# Replace channel mentions <#123> -> #general
|
||||
for channel in message.channel_mentions:
|
||||
content = content.replace(f'<#{channel.id}>', f'#{channel.name}')
|
||||
|
||||
# Replace role mentions <@&123> -> @admin
|
||||
for role in message.role_mentions:
|
||||
content = content.replace(f'<@&{role.id}>', f'@{role.name}')
|
||||
|
||||
# Replace user mentions <@123> or <@!123> -> @User
|
||||
for mention in message.mentions:
|
||||
if mention.id != self.user.id:
|
||||
content = content.replace(f'<@{mention.id}>', f'@{mention.display_name}')
|
||||
content = content.replace(f'<@!{mention.id}>', f'@{mention.display_name}')
|
||||
|
||||
# Remove bot mention
|
||||
content = content.lstrip(f'<@{self.user.id}>').lstrip(f'<@!{self.user.id}>').strip()
|
||||
|
||||
# Handle attachments
|
||||
if message.attachments:
|
||||
for attachment in message.attachments:
|
||||
if attachment.content_type and attachment.content_type.startswith('image/'):
|
||||
content += f" [Image: {attachment.filename}]" if content else f"[Image: {attachment.filename}]"
|
||||
else:
|
||||
content += f" [Fichier: {attachment.filename}]"
|
||||
|
||||
return content
|
||||
|
||||
def get_recent_logs(self, limit: int = 15) -> str:
|
||||
"""Read and format recent action logs for context."""
|
||||
log_path = "data/actions.log"
|
||||
if not os.path.exists(log_path):
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
last_lines = lines[-limit:]
|
||||
|
||||
formatted_logs = []
|
||||
for line in last_lines:
|
||||
if "ACTION:" in line:
|
||||
try:
|
||||
parts = line.split("ACTION:", 1)
|
||||
if len(parts) > 1:
|
||||
json_str = parts[1].strip()
|
||||
data = json.loads(json_str)
|
||||
|
||||
timestamp = data.get("timestamp", "").split("T")[1].split(".")[0]
|
||||
action = data.get("action", "UNKNOWN")
|
||||
user = data.get("user", "System")
|
||||
params = data.get("params", {})
|
||||
success = data.get("success", True)
|
||||
|
||||
# Format details based on action type
|
||||
details = ""
|
||||
if action == 'SEND_MESSAGE':
|
||||
details = f"(to: {params.get('channel_id') or params.get('user_id')}, content: {params.get('content', '')[:20]}...)"
|
||||
elif 'CHANNEL' in action:
|
||||
# Handle channel actions (list of channels)
|
||||
items = params.get('channels', [])
|
||||
if items:
|
||||
names = [i.get('channel_name', 'unknown') for i in items]
|
||||
details = f"(channels: {', '.join(names)})"
|
||||
elif 'ROLE' in action:
|
||||
items = params.get('roles', [])
|
||||
if items:
|
||||
names = [i.get('role_name', 'unknown') for i in items]
|
||||
details = f"(roles: {', '.join(names)})"
|
||||
elif action in ['KICK', 'BAN', 'WARN', 'MUTE', 'TIMEOUT']:
|
||||
target = params.get('user_id', 'unknown')
|
||||
reason = params.get('reason', 'no reason')
|
||||
details = f"(target: {target}, reason: {reason})"
|
||||
elif action == 'PURGE':
|
||||
purges = params.get('purges', [])
|
||||
if purges:
|
||||
p = purges[0]
|
||||
details = f"(channel: {p.get('channel_name')}, amount: {p.get('amount')})"
|
||||
|
||||
status = "✅" if success else "❌"
|
||||
formatted_logs.append(f"[{timestamp}] {status} {action} {details} by {user}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not formatted_logs:
|
||||
return ""
|
||||
|
||||
return "\n[MÉMOIRE DES ACTIONS RÉCENTES (VÉRITÉ)]\n" + "\n".join(formatted_logs) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading actions log: {e}")
|
||||
return ""
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
permissions_info: str,
|
||||
server_context: str,
|
||||
channels_list: str,
|
||||
history: str,
|
||||
content: str,
|
||||
message
|
||||
) -> dict:
|
||||
"""Build LLM payload."""
|
||||
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})"
|
||||
# We include server_context and channels_list so the AI can answer questions about the server
|
||||
prompt = f"{permissions_info}\n{server_context}\n{channels_list}\n{user_name_info}\n{history}\n\nUser: {content}"
|
||||
|
||||
# For llama-cpp-python, we don't need to check for vision models in the same way
|
||||
# Vision support would need to be handled differently with llama-cpp-python
|
||||
vision_model = False # Simplified for now
|
||||
|
||||
return self.llm.build_payload(
|
||||
prompt=prompt,
|
||||
system_prompt=self.system_prompt,
|
||||
vision_model=vision_model,
|
||||
attachments=message.attachments
|
||||
)
|
||||
|
||||
async def _process_response(self, accumulated_reply: str, message, is_monitoring=False):
|
||||
"""Process LLM response, prevent JSON leakage, and scrub technical tags."""
|
||||
# Append closing brace if missing (safety)
|
||||
if accumulated_reply.strip() and not accumulated_reply.strip().endswith('}'):
|
||||
accumulated_reply = accumulated_reply.strip() + "\n}"
|
||||
|
||||
json_str = self.llm.extract_json_actions(accumulated_reply)
|
||||
action_executed = False
|
||||
|
||||
# 1. Nettoyer la réponse textuelle (texte hors-JSON)
|
||||
clean_reply = accumulated_reply
|
||||
if json_str:
|
||||
# On retire le bloc JSON identifié
|
||||
clean_reply = clean_reply.replace(json_str, "").strip()
|
||||
|
||||
# 2. Nettoyage Universel : Suppression des balises hallucinées (<|channel|>, thought, etc.)
|
||||
# On supprime tout ce qui est entre < > (tags techniques)
|
||||
clean_reply = re.sub(r'<[^>]+?>', '', clean_reply)
|
||||
|
||||
# 3. Nettoyage des résidus de formatage
|
||||
clean_reply = clean_reply.replace('```json', '').replace('```', '').strip()
|
||||
|
||||
# 4. Suppression des clés orphelines si le modèle a foiré le JSON
|
||||
clean_reply = re.sub(r'[“"”\'‘](thought|action|response)[“"”\'’]\s*:\s*[“"”\'‘].*?[“"”\'’],?', '', clean_reply, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# 5. Retrait ultime des accolades
|
||||
if clean_reply.strip().startswith('{') or clean_reply.strip().endswith('}'):
|
||||
clean_reply = clean_reply.strip('{}').strip()
|
||||
|
||||
clean_reply = clean_reply.strip()
|
||||
|
||||
is_explicit_none = False
|
||||
responses = []
|
||||
full_response_for_memory = ""
|
||||
|
||||
if json_str:
|
||||
try:
|
||||
# Normalisation JSON (guillemets)
|
||||
json_str_norm = json_str.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'")
|
||||
data = json.loads(json_str_norm)
|
||||
actions_list = data if isinstance(data, list) else [data]
|
||||
|
||||
for action_data in actions_list:
|
||||
# Signaux BETA (Alert/Insight)
|
||||
await self._handle_beta_signals(action_data, message)
|
||||
|
||||
# Normalisation des accès aux clés
|
||||
norm_data = {k.lower(): v for k, v in action_data.items()}
|
||||
|
||||
if norm_data.get('action', '').upper() == 'NONE' and not norm_data.get('response'):
|
||||
is_explicit_none = True
|
||||
|
||||
if norm_data.get('response'):
|
||||
# Scrub tags from the specific response field too
|
||||
resp_text = re.sub(r'<[^>]+?>', '', str(norm_data.get('response'))).strip()
|
||||
if resp_text:
|
||||
responses.append(resp_text)
|
||||
|
||||
# Exécution des actions système
|
||||
res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring)
|
||||
if res: action_executed = True
|
||||
except Exception as e:
|
||||
logger.debug(f"JSON parsing/processing error: {e}")
|
||||
|
||||
# Reconstruction de la mémoire de conversation
|
||||
final_responses_text = "\n".join(responses)
|
||||
if final_responses_text:
|
||||
full_response_for_memory = final_responses_text
|
||||
elif clean_reply:
|
||||
full_response_for_memory = clean_reply
|
||||
|
||||
# Store interaction
|
||||
user_id = str(message.author.id)
|
||||
guild_id = sanitize_guild_name(message.guild.name) if message.guild else None
|
||||
mem_response = full_response_for_memory if not is_monitoring else ""
|
||||
# Nettoyage final des tags techniques pour la mémoire
|
||||
mem_response = re.sub(r'<[^>]+?>', '', mem_response).strip()
|
||||
await add_interaction(user_id, message.channel.id, message.content, mem_response, guild_id)
|
||||
|
||||
# En mode monitoring, silence sauf demande explicite
|
||||
if is_monitoring and not action_executed:
|
||||
return
|
||||
|
||||
# Envoi de la réponse finale si pas déjà fait par une action
|
||||
if not action_executed:
|
||||
final_output = final_responses_text if responses else clean_reply
|
||||
# Nettoyage final avant envoi
|
||||
final_output = re.sub(r'<[^>]+?>', '', final_output).strip()
|
||||
|
||||
if final_output:
|
||||
logger.debug(f"◈ Sending response: {final_output[:100]}...")
|
||||
await self.messaging.reply_with_limit(message, format_mentions_in_text(final_output))
|
||||
elif not is_monitoring:
|
||||
# Si on a un JSON avec une response, l'extraire
|
||||
if json_str:
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
actions = data if isinstance(data, list) else [data]
|
||||
for act in actions:
|
||||
resp = act.get('response', '')
|
||||
if resp:
|
||||
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
|
||||
return
|
||||
except:
|
||||
pass
|
||||
logger.debug(f"◈ Raw response was: {accumulated_reply[:200]}...")
|
||||
fallback_msg = "Hmm, quelque chose s'est mal passé dans ma réponse. Tu peux reformuler ?"
|
||||
await self.messaging.reply_with_limit(message, fallback_msg)
|
||||
|
||||
async def _handle_beta_signals(self, action_data, message):
|
||||
"""Handle Alert (Relevant) and Insight (Irrelevant) signals with full context."""
|
||||
insight = action_data.get('insight')
|
||||
alert = action_data.get('alert')
|
||||
importance = action_data.get('importance', 0)
|
||||
|
||||
# Préparation du bloc de données structurées
|
||||
context_data = {
|
||||
"author_mention": message.author.mention,
|
||||
"author_name": message.author.display_name,
|
||||
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
|
||||
"content": f"*{message.content[:250]}{'...' if len(message.content) > 250 else ''}*",
|
||||
"jump_url": message.jump_url if message.jump_url else None
|
||||
}
|
||||
|
||||
# 1. ALERT -> L'ADMIN (Relevant) - Triage Critique
|
||||
if alert and self.admin_ids:
|
||||
# Formatage de l'alerte pour l'Admin (markdown propre)
|
||||
admin_trigger_text = (
|
||||
f"{alert}\n\n"
|
||||
f"**◈ CONTEXTE DÉTECTION ◈**\n"
|
||||
f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n"
|
||||
f"- **Salon** : {context_data['channel_mention']}\n"
|
||||
+ (f"- **Lien direct** : [Accéder au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale Directe")
|
||||
)
|
||||
|
||||
for admin_id in self.admin_ids:
|
||||
admin = self.get_user(admin_id) or await self.fetch_user(admin_id)
|
||||
if admin:
|
||||
await self.messaging.send_embed(
|
||||
admin,
|
||||
f"ALERTE RELEVANT (Priorité {importance}/10)",
|
||||
admin_trigger_text,
|
||||
color=discord.Color.red()
|
||||
)
|
||||
|
||||
# 2. INSIGHT -> STAFF (Irrelevant) - Triage Opérationnel
|
||||
if insight and self.asset_ids:
|
||||
if alert:
|
||||
logger.warning(f"◈ ALERT RELEVANT: {alert}")
|
||||
else:
|
||||
if importance > 7:
|
||||
logger.info(f"◈ INSIGHT IRRELEVANT (High Importance {importance}): {insight[:50]}...")
|
||||
|
||||
# Utilisation du Dispatcher avec données structurées
|
||||
await self.dispatcher.dispatch_insight(insight, importance, message.guild, context_data)
|
||||
|
||||
async def _execute_actions(self, action_data, message, final_content, is_monitoring=False) -> bool:
|
||||
"""Execute JSON actions and provide feedback."""
|
||||
action_executed = False
|
||||
|
||||
async def process_item(item):
|
||||
nonlocal action_executed
|
||||
if not isinstance(item, dict) or 'action' not in item:
|
||||
return
|
||||
|
||||
action = item['action']
|
||||
|
||||
resp = item.get('response', "").strip()
|
||||
if resp and not is_monitoring:
|
||||
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
|
||||
action_executed = True
|
||||
|
||||
if action != 'NONE':
|
||||
success, result = await self.action_router.execute_action(action, item, message)
|
||||
action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)
|
||||
|
||||
# Feedback to user for systemic actions if not in monitoring
|
||||
if not is_monitoring:
|
||||
if success:
|
||||
if result: # Some actions return a success string
|
||||
await self.messaging.reply_with_limit(message, f"◈ SUCCESS: {result}")
|
||||
else:
|
||||
# Default success feedback for important actions if they are silent
|
||||
if action in ['JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER']:
|
||||
confirmations = {
|
||||
'JOIN_VOICE': "Signal reçu. Je rejoins le salon vocal.",
|
||||
'LEAVE_VOICE': "Opération terminée. Je quitte le salon vocal.",
|
||||
'FORGET_USER': "Protocole d'effacement terminé. Vos données ont été supprimées."
|
||||
}
|
||||
await self.messaging.reply_with_limit(message, f"◈ {confirmations.get(action, 'Action exécutée avec succès.')}")
|
||||
else:
|
||||
# Error feedback
|
||||
err_msg = result or "Une erreur est survenue lors de l'exécution."
|
||||
await self.messaging.reply_with_limit(message, f"◈ ERREUR: {err_msg}")
|
||||
|
||||
action_executed = True
|
||||
|
||||
if isinstance(action_data, list):
|
||||
executed = set()
|
||||
for item in action_data:
|
||||
key = json.dumps(item, sort_keys=True)
|
||||
if key in executed: continue
|
||||
executed.add(key)
|
||||
await process_item(item)
|
||||
|
||||
elif isinstance(action_data, dict):
|
||||
await process_item(action_data)
|
||||
|
||||
return action_executed
|
||||
# ==================== Metrics & Cleanup ====================
|
||||
|
||||
def get_metrics(self) -> Dict:
|
||||
"""Return runtime metrics."""
|
||||
merged = {**self.metrics}
|
||||
try:
|
||||
from brain.memoire import get_metrics as _m
|
||||
mem = _m()
|
||||
merged["memory"] = mem
|
||||
except Exception:
|
||||
merged["memory"] = {}
|
||||
if self.redis_worker:
|
||||
merged["queue_pending"] = len(self.redis_worker._pending_jobs)
|
||||
return merged
|
||||
|
||||
async def close(self):
|
||||
"""Clean shutdown."""
|
||||
try:
|
||||
await flush_all()
|
||||
except Exception:
|
||||
logger.exception("Error flushing memories")
|
||||
|
||||
await self.llm.close_session()
|
||||
|
||||
if self.redis_worker:
|
||||
await self.redis_worker.close()
|
||||
|
||||
await super().close()
|
||||
|
||||
93
core/context.py
Normal file
93
core/context.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Context building utilities for Superviseur bot."""
|
||||
|
||||
import discord
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def build_server_context(guild: discord.Guild) -> str:
|
||||
"""Build a comprehensive context string for the server."""
|
||||
if not guild:
|
||||
return "\nContexte : Aucun serveur (Message Direct)."
|
||||
|
||||
context = f"\nInformations sur le serveur '{guild.name}' (ID: {guild.id}):\n"
|
||||
context += f"- Membres: {guild.member_count}\n"
|
||||
context += f"- Propriétaire: {guild.owner.display_name if guild.owner else 'Inconnu'}\n"
|
||||
context += f"- Créé le: {guild.created_at.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
|
||||
|
||||
# Channels
|
||||
text_channels = [ch.name for ch in guild.channels if isinstance(ch, discord.TextChannel)]
|
||||
voice_channels = [ch.name for ch in guild.channels if isinstance(ch, discord.VoiceChannel)]
|
||||
categories = [cat.name for cat in guild.channels if isinstance(cat, discord.CategoryChannel)]
|
||||
|
||||
if text_channels:
|
||||
context += f"- Salons textes: {len(text_channels)} ({', '.join(text_channels[:5])}...)\n"
|
||||
else:
|
||||
context += "- Salons textes: 0\n"
|
||||
|
||||
context += f"- Salons vocaux: {len(voice_channels)}\n"
|
||||
|
||||
if categories:
|
||||
context += f"- Catégories: {len(categories)} ({', '.join(categories[:3])}...)\n"
|
||||
else:
|
||||
context += "- Catégories: 0\n"
|
||||
|
||||
# Roles (except @everyone)
|
||||
roles = [role.name for role in guild.roles[1:]] # Skip @everyone
|
||||
if roles:
|
||||
context += f"- Rôles: {len(roles)}\n"
|
||||
for role_name in roles:
|
||||
context += f" - {role_name}\n"
|
||||
else:
|
||||
context += "- Rôles: 0\n"
|
||||
|
||||
# Liste des membres (si le serveur n'est pas trop grand)
|
||||
if len(guild.members) <= 200:
|
||||
members_list = [m.display_name for m in guild.members]
|
||||
context += f"- Tous les membres ({len(members_list)}): {', '.join(members_list)}\n"
|
||||
else:
|
||||
recent_members = sorted(guild.members, key=lambda m: m.joined_at or m.created_at, reverse=True)[:5]
|
||||
context += f"- Membres récents: {', '.join([m.display_name for m in recent_members])}\n"
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def build_channels_list(guild: discord.Guild) -> str:
|
||||
"""Build a formatted list of all channels with IDs."""
|
||||
if not guild:
|
||||
return ""
|
||||
|
||||
lines = ["\nListe des salons (nom | id | type):"]
|
||||
for ch in guild.channels:
|
||||
if isinstance(ch, discord.TextChannel):
|
||||
ch_type = 'textuel'
|
||||
elif isinstance(ch, discord.VoiceChannel):
|
||||
ch_type = 'vocal'
|
||||
else:
|
||||
ch_type = 'catégorie'
|
||||
lines.append(f"- {ch.name} | {ch.id} | {ch_type}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_context_for_message(
|
||||
guild: discord.Guild,
|
||||
channels_list_cache: dict,
|
||||
server_context_cache: dict
|
||||
) -> Tuple[str, str]:
|
||||
"""Build channels list and server context with caching."""
|
||||
if not guild:
|
||||
return "", build_server_context(None)
|
||||
|
||||
guild_id = guild.id
|
||||
|
||||
# Build channels list with caching
|
||||
if guild_id not in channels_list_cache:
|
||||
channels_list_cache[guild_id] = build_channels_list(guild)
|
||||
channels_list = channels_list_cache[guild_id]
|
||||
|
||||
# Build server context with caching
|
||||
if guild_id not in server_context_cache:
|
||||
server_context_cache[guild_id] = build_server_context(guild)
|
||||
server_context = server_context_cache[guild_id]
|
||||
|
||||
return channels_list, server_context
|
||||
|
||||
234
core/dispatcher.py
Normal file
234
core/dispatcher.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
import discord
|
||||
from .permissions import check_is_admin
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json")
|
||||
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json")
|
||||
|
||||
class AssetDispatcher:
|
||||
"""Manages autonomous staff coordination and task assignments."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.assets = self._load_data(ASSETS_DATA_PATH)
|
||||
self.tasks = self._load_data(TASKS_DATA_PATH)
|
||||
self.interview_states = {} # {user_id: bool}
|
||||
|
||||
def _load_data(self, path):
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _save_data(self, data, path):
|
||||
with open(path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
async def start_interview(self, user_id: int):
|
||||
"""Initiate the autonomous interview protocol via DM."""
|
||||
if str(user_id) in self.assets:
|
||||
return
|
||||
|
||||
user = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id)
|
||||
if not user: return
|
||||
|
||||
self.interview_states[user_id] = True
|
||||
prompt = (
|
||||
"◈ PROTOCOLE D'ENTRETIEN : ACQUISITION DE DISPONIBILITÉS ◈\n\n"
|
||||
"Bonjour Asset Primaire. Pour optimiser la coordination du système, j'ai besoin de connaître vos disponibilités.\n"
|
||||
"Veuillez m'indiquer vos horaires habituels (cours, travail, repos) ainsi que votre fuseau horaire.\n"
|
||||
"Répondez-moi naturellement, je traiterai vos données."
|
||||
)
|
||||
await self.bot.messaging.send_embed(user, "COMMUNICATION ENTRANTE", prompt, color=discord.Color.blue(), is_asset=True)
|
||||
|
||||
async def handle_dm_response(self, message: discord.Message):
|
||||
"""Process natural language response from an Asset for their schedule."""
|
||||
u_id = str(message.author.id)
|
||||
if u_id not in self.interview_states: return False
|
||||
|
||||
# Indiquer que Bêta réfléchit
|
||||
async with message.channel.typing():
|
||||
# Use LLM to extract schedule
|
||||
prompt = (
|
||||
f"ANALYSE DE DISPONIBILITÉ ASSET\n"
|
||||
f"Texte du membre : \"{message.content}\"\n\n"
|
||||
f"FONCTION : Extraire les horaires au format JSON strict.\n"
|
||||
f"FORMAT REQUIS :\n"
|
||||
f"`{{\"schedule\": {{\"mon\": [[\"HH:MM\", \"HH:MM\"]], \"tue\": [], ...}}, \"timezone\": \"Europe/Paris\"}}`\n"
|
||||
f"Note : Si l'Asset dit être libre tout le temps, laissez les jours vides []. S'il donne des heures, mettez-les.\n"
|
||||
f"IMPORTANT: Ne répondez QUE par le bloc JSON."
|
||||
)
|
||||
|
||||
try:
|
||||
payload = self.bot.llm.build_payload(
|
||||
prompt=prompt,
|
||||
system_prompt="Tu es un expert en extraction de données. Réponds uniquement en JSON.",
|
||||
force_json=True
|
||||
)
|
||||
resp = await self.bot.llm.call_llama(payload)
|
||||
json_str = self.bot.llm.extract_json_actions(resp)
|
||||
|
||||
if not json_str and resp.strip().startswith('{'):
|
||||
json_str = resp.strip()
|
||||
|
||||
if json_str:
|
||||
data = json.loads(json_str)
|
||||
if isinstance(data, list): data = data[0]
|
||||
|
||||
if "schedule" in data:
|
||||
self.assets[u_id] = {
|
||||
"name": message.author.display_name,
|
||||
"schedule": data.get("schedule", {}),
|
||||
"timezone": data.get("timezone", "Europe/Paris"),
|
||||
"last_update": time.time()
|
||||
}
|
||||
self._save_data(self.assets, ASSETS_DATA_PATH)
|
||||
|
||||
del self.interview_states[u_id]
|
||||
await self.bot.messaging.send_embed(
|
||||
message.author,
|
||||
"INTÉGRATION RÉUSSIE",
|
||||
"Vos paramètres de disponibilité ont été synchronisés avec succès. Je vous contacterai selon ces critères.",
|
||||
color=discord.Color.green(),
|
||||
is_asset=True
|
||||
)
|
||||
return True
|
||||
|
||||
# Si on arrive ici, l'extraction a échoué
|
||||
logger.warning(f"Failed to extract schedule from: {resp}")
|
||||
await self.bot.messaging.send_embed(
|
||||
message.author,
|
||||
"ÉCHEC D'ACQUISITION",
|
||||
"Je n'ai pas pu parser vos horaires. Veuillez être plus précis (ex: 'Lundi de 8h à 12h') ou vérifier le format.",
|
||||
color=discord.Color.orange(),
|
||||
is_asset=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process interview response: {e}")
|
||||
await message.channel.send(f"◈ ERREUR CRITIQUE DURANT L'ANALYSE : {e}")
|
||||
|
||||
return True
|
||||
|
||||
def is_available(self, user_id: int, guild: discord.Guild) -> bool:
|
||||
"""Check if an asset is available and physically present on the server."""
|
||||
u_id = str(user_id)
|
||||
|
||||
# 1. Server Presence & Permission Check (only if guild is provided)
|
||||
if guild:
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
return False
|
||||
|
||||
# Verify they are ACTUALLY staff on this server
|
||||
if not check_is_admin(member.guild_permissions):
|
||||
# logger.warning(f"Asset {member.display_name} is present but lacks STAFF permissions on {guild.name}")
|
||||
return False
|
||||
|
||||
|
||||
# 2. Schedule Check
|
||||
asset = self.assets.get(u_id)
|
||||
if not asset: return True # Default available if unknown
|
||||
|
||||
# Simple weekday/hour check (basic implementation)
|
||||
now = datetime.datetime.now() # Should use asset's timezone in real impl
|
||||
day = now.strftime("%a").lower()[:3]
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
schedule = asset.get("schedule", {}).get(day, [])
|
||||
if not schedule: return True # Available if no specific "busy" blocks
|
||||
|
||||
for start, end in schedule:
|
||||
if start <= current_time <= end:
|
||||
return False # Occupied
|
||||
|
||||
return True
|
||||
|
||||
async def dispatch_insight(self, insight: str, importance: int, guild: discord.Guild, context_data: dict):
|
||||
"""Dispatch an insight to the best available asset."""
|
||||
available_assets = []
|
||||
for asset_id in self.bot.asset_ids:
|
||||
if self.is_available(asset_id, guild):
|
||||
available_assets.append(asset_id)
|
||||
|
||||
if not available_assets:
|
||||
# Fallback to Admins if no asset available
|
||||
for admin_id in self.bot.admin_ids:
|
||||
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
|
||||
if admin:
|
||||
await self.bot.messaging.send_embed(admin, "DISPATCH FALLBACK: NO ASSETS AVAILABLE", insight, color=discord.Color.gold())
|
||||
return
|
||||
|
||||
# Target selection (Simple round-robin or first available for now)
|
||||
target_id = available_assets[0]
|
||||
target_user = self.bot.get_user(target_id) or await self.bot.fetch_user(target_id)
|
||||
if target_user:
|
||||
# Create Task ID and Data
|
||||
task_id = f"task_{int(time.time())}"
|
||||
self.tasks[task_id] = {
|
||||
"asset_id": target_id,
|
||||
"content": insight,
|
||||
"importance": importance,
|
||||
"created_at": time.time(),
|
||||
"deadline": time.time() + (60 * (11 - importance) * 5),
|
||||
"guild_id": guild.id if guild else None,
|
||||
"status": "PENDING"
|
||||
}
|
||||
self._save_data(self.tasks, TASKS_DATA_PATH)
|
||||
|
||||
view = TaskAcceptView(task_id, self)
|
||||
|
||||
# Création des champs d'information structurés
|
||||
fields = [
|
||||
{"name": "👤 Sujet", "value": f"{context_data['author_mention']} ({context_data['author_name']})", "inline": True},
|
||||
{"name": "📍 Salon", "value": context_data['channel_mention'], "inline": True},
|
||||
{"name": "📝 Contenu", "value": context_data['content'], "inline": False}
|
||||
]
|
||||
|
||||
# Ajout du lien direct s'il existe
|
||||
if context_data.get('jump_url'):
|
||||
fields.append({"name": "🔗 Lien Direct", "value": f"[Accéder au message]({context_data['jump_url']})", "inline": False})
|
||||
|
||||
desc = (
|
||||
f"**MISSION OPÉRATIONNELLE**\n"
|
||||
f"{insight}\n\n"
|
||||
f"*Veuillez confirmer la prise en charge pour verrouiller la tâche.*"
|
||||
)
|
||||
|
||||
await self.bot.messaging.send_embed(
|
||||
target_user,
|
||||
f"ASSIGNATION TACTIQUE (Priorité {importance}/10)",
|
||||
desc,
|
||||
color=discord.Color.blue(),
|
||||
is_asset=True,
|
||||
fields=fields
|
||||
)
|
||||
# Send view separately
|
||||
await target_user.send(view=view)
|
||||
|
||||
class TaskAcceptView(discord.ui.View):
|
||||
def __init__(self, task_id, dispatcher):
|
||||
super().__init__(timeout=None)
|
||||
self.task_id = task_id
|
||||
self.dispatcher = dispatcher
|
||||
|
||||
@discord.ui.button(label="Accepter la tâche", style=discord.ButtonStyle.green)
|
||||
async def accept(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
task = self.dispatcher.tasks.get(self.task_id)
|
||||
if task:
|
||||
task["status"] = "ACCEPTED"
|
||||
task["accepted_at"] = time.time()
|
||||
self.dispatcher._save_data(self.dispatcher.tasks, TASKS_DATA_PATH)
|
||||
|
||||
button.disabled = True
|
||||
button.label = "Acceptée ✅"
|
||||
await interaction.response.edit_message(view=self)
|
||||
await interaction.followup.send("◈ Tâche verrouillée. Bonne intervention.", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("Tâche introuvable ou expirée.", ephemeral=True)
|
||||
160
core/llm.py
Normal file
160
core/llm.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Ollama integration for Superviseur bot - replaces llama-cpp-python for better stability."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import re
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
class LLMManager:
|
||||
"""Manages LLM interactions via Ollama API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "gpt-oss:20b",
|
||||
base_url: str = None,
|
||||
temperature: float = 0.5
|
||||
):
|
||||
self.model_name = model_name
|
||||
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
self.temperature = temperature
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def build_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
vision_model: bool = False,
|
||||
attachments: Optional[list] = None,
|
||||
force_json: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the payload for Ollama Generate API."""
|
||||
return {
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"system": system_prompt or "Tu es une IA serviable.",
|
||||
"stream": True, # Streaming pour voir la réponse en direct dans le terminal
|
||||
"format": "json" if force_json else "",
|
||||
"options": {
|
||||
"temperature": self.temperature,
|
||||
"num_ctx": 16384,
|
||||
"repeat_penalty": 1.2,
|
||||
"num_predict": 4096,
|
||||
"top_p": 0.9,
|
||||
"top_k": 40
|
||||
}
|
||||
}
|
||||
|
||||
async def call_llama(self, payload: Dict[str, Any]) -> str:
|
||||
"""Call Ollama Generate API and stream response."""
|
||||
async with self._lock:
|
||||
try:
|
||||
# Automatic payload conversion for legacy compatibility (e.g. from moderation.py)
|
||||
ollama_payload = {
|
||||
"model": payload.get("model", self.model_name),
|
||||
"prompt": payload["prompt"],
|
||||
"system": payload.get("system", payload.get("system_prompt", "Tu es une IA serviable.")),
|
||||
"stream": True, # Streaming pour voir la réponse en direct
|
||||
"think": False, # Désactive le reasoning GPT-OSS
|
||||
"format": "",
|
||||
"options": {
|
||||
"temperature": payload.get("temperature", self.temperature),
|
||||
"num_ctx": payload.get("n_ctx", 16384),
|
||||
"num_predict": payload.get("max_tokens") or payload.get("num_predict", 8192),
|
||||
"repeat_penalty": payload.get("repeat_penalty") or payload.get("repeat_last_n", 1.2),
|
||||
"stop": payload.get("stop", [])
|
||||
}
|
||||
}
|
||||
|
||||
accumulated_reply = ""
|
||||
api_url = f"{self.base_url}/api/generate"
|
||||
|
||||
# Build headers with optional API key
|
||||
headers = {}
|
||||
api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(api_url, json=ollama_payload, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
err_text = await response.text()
|
||||
logger.error(f"Ollama API Error ({response.status}): {err_text}")
|
||||
return ""
|
||||
|
||||
# Streaming en direct dans le terminal
|
||||
async for line in response.content:
|
||||
if line:
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
resp = chunk.get("response", "")
|
||||
if resp:
|
||||
accumulated_reply += resp
|
||||
print(resp, end="", flush=True)
|
||||
if chunk.get("done"):
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
print()
|
||||
return accumulated_reply.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling Ollama: {e}")
|
||||
return ""
|
||||
|
||||
async def close_session(self):
|
||||
pass
|
||||
|
||||
def extract_json_actions(self, text: str) -> Optional[str]:
|
||||
"""
|
||||
Extract JSON action objects from text with surgical precision.
|
||||
Handles both single objects {...} and arrays [...].
|
||||
Also handles // comments that GPT-OSS sometimes inserts.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Remove // comments (GPT-OSS sometimes adds them)
|
||||
text = re.sub(r'//.*?\n', '\n', text)
|
||||
text = re.sub(r'//.*?$', '', text, flags=re.MULTILINE)
|
||||
|
||||
# 1. Try to find a JSON array [...]
|
||||
first_bracket = text.find('[')
|
||||
if first_bracket != -1:
|
||||
last_bracket = text.rfind(']')
|
||||
if last_bracket > first_bracket:
|
||||
array_str = text[first_bracket:last_bracket+1]
|
||||
try:
|
||||
json.loads(array_str)
|
||||
return array_str
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 2. Try to find a JSON object {...}
|
||||
first_brace = text.find('{')
|
||||
if first_brace == -1:
|
||||
return None
|
||||
|
||||
last_brace = text.rfind('}')
|
||||
if last_brace == -1 or last_brace < first_brace:
|
||||
return None
|
||||
|
||||
stack = []
|
||||
for i in range(first_brace, len(text)):
|
||||
if text[i] == '{':
|
||||
stack.append('{')
|
||||
elif text[i] == '}':
|
||||
if stack:
|
||||
stack.pop()
|
||||
if not stack:
|
||||
match = text[first_brace:i+1]
|
||||
if '"action"' in match.lower():
|
||||
return match
|
||||
|
||||
return None
|
||||
370
core/voice.py
Normal file
370
core/voice.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import discord
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, List
|
||||
from .whisper_worker import WhisperWorker
|
||||
|
||||
logger = logging.getLogger('Superviseur')
|
||||
|
||||
# Note: This requires discord-ext-voice-recv for actual audio capture.
|
||||
try:
|
||||
from discord.ext import voice_recv
|
||||
HAS_VOICE_RECV = True
|
||||
_BaseSink = voice_recv.AudioSink
|
||||
except ImportError:
|
||||
HAS_VOICE_RECV = False
|
||||
_BaseSink = object # Fallback to prevent crash
|
||||
|
||||
class BetaAudioSink(_BaseSink):
|
||||
"""Custom sink that performs manual decoding to handle Opus errors gracefully."""
|
||||
def __init__(self, voice_manager):
|
||||
super().__init__()
|
||||
self.manager = voice_manager
|
||||
self.user_buffers = {} # {id: {"name": str, "data": bytearray, "decoder": Decoder}}
|
||||
|
||||
def wants_opus(self) -> bool:
|
||||
"""Request Opus packets to handle decoding manually and safely."""
|
||||
return True
|
||||
|
||||
def write(self, user, data):
|
||||
if user is None: return
|
||||
u_id = user.id
|
||||
|
||||
# Initialisation du décodeur pour ce sujet si besoin
|
||||
if u_id not in self.user_buffers:
|
||||
logger.debug(f"◈ AudioSink: Initializing resilient decoder for {user.display_name}")
|
||||
self.user_buffers[u_id] = {
|
||||
"name": user.display_name,
|
||||
"data": bytearray(),
|
||||
"decoder": discord.opus.Decoder()
|
||||
}
|
||||
|
||||
# Décodage manuel avec capture d'erreur
|
||||
try:
|
||||
# data.opus contient le paquet brut car wants_opus = True
|
||||
pcm_data = self.user_buffers[u_id]["decoder"].decode(data.opus)
|
||||
if pcm_data:
|
||||
self.user_buffers[u_id]["data"].extend(pcm_data)
|
||||
except discord.opus.OpusError:
|
||||
# On ignore silencieusement les paquets corrompus (souvent au début)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"◈ AudioSink Error: Unexpected decoding failure: {e}")
|
||||
|
||||
def cleanup(self):
|
||||
self.user_buffers.clear()
|
||||
logger.debug("◈ AudioSink: Session resources released.")
|
||||
|
||||
class VoiceManager:
|
||||
"""Manages tactical voice channel connections and audio monitoring."""
|
||||
HAS_VOICE_RECV = HAS_VOICE_RECV
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.active_connections: Dict[int, discord.VoiceClient] = {}
|
||||
self.current_channel: Optional[discord.VoiceChannel] = None
|
||||
self.whisper = WhisperWorker(device="cpu") # Stable direct fallback
|
||||
self.is_monitoring = False
|
||||
self._monitoring_task: Optional[asyncio.Task] = None
|
||||
self.session_buffer: List[Dict[str, Any]] = [] # [{"user": name, "text": str, "timestamp": ts}]
|
||||
self.session_start = 0
|
||||
self.active_sink: Optional[BetaAudioSink] = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Lazy load Whisper to preserve startup time."""
|
||||
# Run in executor to not block bot ready
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, self.whisper.initialize)
|
||||
|
||||
|
||||
async def evaluate_and_connect(self, guild):
|
||||
"""Analyze voice channels in guild and connect if strategically relevant."""
|
||||
if self.current_channel and self.current_channel.guild == guild:
|
||||
return
|
||||
|
||||
best_channel = None
|
||||
max_members = 0
|
||||
|
||||
for vc in guild.voice_channels:
|
||||
# We count actual members, excluding bots
|
||||
members = [m for m in vc.members if not m.bot]
|
||||
if len(members) > max_members:
|
||||
max_members = len(members)
|
||||
best_channel = vc
|
||||
|
||||
if best_channel and max_members >= 1:
|
||||
logger.info(f"◈ TACTICAL EVALUATION: Target acquired: '{best_channel.name}' in {guild.name}")
|
||||
await self.join_channel(best_channel)
|
||||
|
||||
async def join_channel(self, channel: discord.VoiceChannel):
|
||||
"""Securely join a voice channel with permission checking."""
|
||||
try:
|
||||
# Physical authorization check
|
||||
me = channel.guild.me
|
||||
perms = channel.permissions_for(me)
|
||||
if not perms.connect or not perms.view_channel:
|
||||
logger.error(f"◈ REJECTION: Missing permissions for '{channel.name}' (CONNECT: {perms.connect}, VIEW: {perms.view_channel})")
|
||||
await self.bot.introspection_log(
|
||||
"DEPLOYMENT FAILURE",
|
||||
f"Permissions insuffisantes pour `{channel.name}`. Système incapable de se déployer.",
|
||||
discord.Color.red()
|
||||
)
|
||||
return
|
||||
|
||||
active_vc = channel.guild.voice_client
|
||||
|
||||
# Si on est déjà connecté
|
||||
if active_vc and active_vc.channel == channel:
|
||||
# Mais qu'on n'est pas dans la bonne classe ou que le monitoring n'est pas lancé
|
||||
if HAS_VOICE_RECV and not isinstance(active_vc, voice_recv.VoiceRecvClient):
|
||||
logger.info("◈ SYSTEM: Upgrading Voice Client to RecvClient.")
|
||||
await self.leave_current()
|
||||
elif self.is_monitoring:
|
||||
return # Tout est déjà opérationnel
|
||||
else:
|
||||
# On continue pour lancer le monitoring sur le vc existant
|
||||
vc = active_vc
|
||||
else:
|
||||
if active_vc:
|
||||
await self.leave_current()
|
||||
|
||||
logger.info(f"◈ SYSTEM: Joining Voice Channel '{channel.name}' (Priority Acquisition)")
|
||||
await self.bot.introspection_log(
|
||||
"VOICE DEPLOYMENT",
|
||||
f"Déploiment tactique dans `{channel.name}` (Priorité identifiée).",
|
||||
discord.Color.blue()
|
||||
)
|
||||
|
||||
# Utilisation du client spécialisé pour la réception audio
|
||||
connect_cls = voice_recv.VoiceRecvClient if HAS_VOICE_RECV else discord.VoiceClient
|
||||
vc = await channel.connect(cls=connect_cls)
|
||||
|
||||
self.active_connections[channel.guild.id] = vc
|
||||
self.current_channel = channel
|
||||
|
||||
if HAS_VOICE_RECV:
|
||||
# Start the ear protocol
|
||||
self.is_monitoring = True
|
||||
self.session_buffer = []
|
||||
self.session_start = time.time()
|
||||
self.active_sink = BetaAudioSink(self)
|
||||
self._monitoring_task = self.bot.loop.create_task(self._start_listening(vc))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join voice channel: {e}")
|
||||
|
||||
async def _start_listening(self, vc):
|
||||
"""Internal loop to capture and transcribe audio chunks."""
|
||||
logger.info("◈ SYSTEM: Ear Protocol Engaged. Monitoring Voice Stream.")
|
||||
|
||||
# On attache le sink
|
||||
try:
|
||||
vc.listen(self.active_sink)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start listening sink: {e}")
|
||||
return
|
||||
|
||||
import wave
|
||||
import tempfile
|
||||
|
||||
while self.is_monitoring and vc.is_connected():
|
||||
await asyncio.sleep(7) # Process chunks every 7s (faster response)
|
||||
|
||||
# Rotation des buffers
|
||||
current_buffers = self.active_sink.user_buffers
|
||||
self.active_sink.user_buffers = {}
|
||||
|
||||
if not current_buffers:
|
||||
logger.debug("◈ Voice Processor: No audio data in current buffers.")
|
||||
continue
|
||||
|
||||
for u_id, buffer_data in current_buffers.items():
|
||||
data_len = len(buffer_data["data"])
|
||||
logger.debug(f"◈ Voice Processor: Processing {data_len} bytes from {buffer_data['name']}")
|
||||
|
||||
if data_len < 5000: # On monte le seuil à ~50ms pour filtrer les bruits de fond
|
||||
continue
|
||||
|
||||
# Conversion PCM brut -> WAV (Whisper en a besoin)
|
||||
# Discord envoie du 48kHz Stereo 16-bit PCM
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
with wave.open(tmp_path, 'wb') as wav_file:
|
||||
wav_file.setnchannels(2)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(48000)
|
||||
wav_file.writeframes(buffer_data["data"])
|
||||
|
||||
# Transcription
|
||||
text = await self.whisper.transcribe(tmp_path)
|
||||
|
||||
if text and len(text.strip()) > 3:
|
||||
logger.debug(f"◈ Logged Voice Insight: {buffer_data['name']} -> {text}")
|
||||
self.session_buffer.append({
|
||||
"user": buffer_data["name"],
|
||||
"text": text,
|
||||
"ts": time.time()
|
||||
})
|
||||
|
||||
# NEW: Real-time Voice Trigger Detection
|
||||
trigger_names = ["bêta", "beta", "béta", "béat", "vêta", "veta"]
|
||||
if any(name in text.lower() for name in trigger_names):
|
||||
logger.info(f"◈ SYSTEM: Voice trigger MATCH detected in VOC: '{text}'")
|
||||
|
||||
logger.info(f"◈ SYSTEM: Launching voice interaction bridge for {buffer_data['name']}")
|
||||
# We run it in a task to not block the voice processing loop
|
||||
# Note: we pass None for channel since bot.py will now use DMs
|
||||
self.bot.loop.create_task(
|
||||
self.bot.handle_voice_interaction(
|
||||
user_name=buffer_data["name"],
|
||||
user_id=u_id,
|
||||
content=text,
|
||||
channel=None
|
||||
)
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
try: os.remove(tmp_path)
|
||||
except: pass
|
||||
|
||||
async def generate_session_report(self, advanced: bool = False, custom_buffer=None):
|
||||
"""Analyze accumulated transcriptions and send a clinical summary.
|
||||
If advanced=True, uses the heavy LLM for a deep analysis.
|
||||
"""
|
||||
buffer = custom_buffer if custom_buffer is not None else self.session_buffer
|
||||
|
||||
if not buffer:
|
||||
return "Aucune donnée vocale significative n'a été capturée durant cette session."
|
||||
|
||||
# ANALYSE DE TENSION (Mots-clés ou BSI bas)
|
||||
is_tense = False
|
||||
conflit_keywords = ["fdp", "tg", "nique", "pute", "connard", "merde", "salope"]
|
||||
|
||||
tense_count = 0
|
||||
for item in buffer:
|
||||
if any(k in item['text'].lower() for k in conflit_keywords):
|
||||
tense_count += 1
|
||||
|
||||
if tense_count > 3: # Seuil de tension
|
||||
is_tense = True
|
||||
self.bot.metrics["daily_tense_sessions"] = self.bot.metrics.get("daily_tense_sessions", 0) + 1
|
||||
logger.warning(f"◈ SYSTEM: Tense voice session detected in {self.current_channel}")
|
||||
|
||||
# Concatenate for analysis
|
||||
full_transcript = "\n".join([f"{item['user']}: {item['text']}" for item in buffer])
|
||||
channel_name = self.current_channel.name if self.current_channel else "N/A"
|
||||
duration = int((time.time() - (self.session_start if hasattr(self, 'session_start') else time.time())) / 60)
|
||||
|
||||
if not advanced:
|
||||
# Rapport périodique simple
|
||||
report = [
|
||||
f"**◈ VOICE SESSION STATUS (Périodique) ◈**",
|
||||
f"Salon : `{channel_name}`",
|
||||
f"Durée actuelle : `{duration} minutes`",
|
||||
f"Extraits récents :\n"
|
||||
]
|
||||
# On prend les 10 derniers segments
|
||||
for item in self.session_buffer[-10:]:
|
||||
report.append(f"- **{item['user']}** : \"{item['text'][:100]}\"")
|
||||
return "\n".join(report)
|
||||
|
||||
# RAPPORT AVANCÉ (Fin de session) via HEAVY LLM
|
||||
prompt = (
|
||||
f"PROTOCOLE D'ANALYSE VOCALE FINALE\n"
|
||||
f"Salon : {channel_name}\n"
|
||||
f"Durée totale : {duration} minutes\n"
|
||||
f"Flux de données capturé :\n---\n{full_transcript}\n---\n\n"
|
||||
f"VOTRE MISSION :\n"
|
||||
f"En tant que Bêta, analysez l'intégralité de ce flux. Produisez un rapport clinique structuré incluant :\n"
|
||||
f"1. RÉSUMÉ EXÉCUTIF (Synthèse des échanges)\n"
|
||||
f"2. IDENTIFICATION DES SUJETS (Comportements et attitudes)\n"
|
||||
f"3. POINTS DE VIGILANCE (Alertes Relevant ou insights stratégiques)\n"
|
||||
f"4. CONCLUSION (État de l'écosystème)\n\n"
|
||||
f"IMPORTANT: Pour cette mission spécifique, produisez un rapport en TEXTE BRUT structuré. "
|
||||
f"IGNOREZ la consigne habituelle de format JSON. Ne mettez AUCUN bloc JSON."
|
||||
f"Le ton doit être froid, professionnel et factuel."
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"◈ SYSTEM: Generating advanced report for {channel_name} using Heavy LLM...")
|
||||
|
||||
# Utilisation du manager LLM du bot
|
||||
payload = self.bot.llm.build_payload(
|
||||
prompt=prompt,
|
||||
system_prompt=self.bot.system_prompt,
|
||||
force_json=False
|
||||
)
|
||||
analysis = await self.bot.llm.call_llama(payload)
|
||||
|
||||
# Nettoyage si jamais l'IA renvoie du JSON superflu (Bêta n'est pas censé mais on sait jamais)
|
||||
json_str = self.bot.llm.extract_json_actions(analysis)
|
||||
if json_str:
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
if isinstance(data, list): data = data[0]
|
||||
analysis = data.get('response', analysis)
|
||||
except: pass
|
||||
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"◈ SYSTEM: Failed to generate advanced voice report: {e}")
|
||||
return f"ERREUR ANALYSE IA GÉNÉRALE : {e}\n\n[TRANSCRIPT DE SECOURS]\n{full_transcript[:1800]}..."
|
||||
|
||||
async def leave_current(self, send_report=True):
|
||||
"""Disconnect immediately and handle report generation in the background."""
|
||||
if not self.active_connections and not self.current_channel:
|
||||
return
|
||||
|
||||
# 1. Capture current session data for background reporting
|
||||
captured_buffer = list(self.session_buffer) if self.session_buffer else []
|
||||
was_monitoring = self.is_monitoring
|
||||
|
||||
# 2. CLEAR STATE IMMEDIATELY (Responsiveness)
|
||||
self.session_buffer = []
|
||||
self.is_monitoring = False
|
||||
if self._monitoring_task:
|
||||
self._monitoring_task.cancel()
|
||||
|
||||
# 3. DISCONNECT IMMEDIATELY
|
||||
for guild_id, vc in list(self.active_connections.items()):
|
||||
try:
|
||||
await vc.disconnect()
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting from voice: {e}")
|
||||
del self.active_connections[guild_id]
|
||||
|
||||
self.current_channel = None
|
||||
logger.info("◈ SYSTEM: Voice Connection Terminated (Resource Preservation)")
|
||||
|
||||
# 4. Background Report Generation
|
||||
if was_monitoring and send_report and captured_buffer:
|
||||
# We use a separate task to avoid blocking the caller (who might be waiting to join another channel)
|
||||
self.bot.loop.create_task(self._process_background_report(captured_buffer))
|
||||
|
||||
async def _process_background_report(self, buffer):
|
||||
"""Internal helper to generate report without blocking the voice client."""
|
||||
try:
|
||||
# Temporarily restore buffer for the report generation call
|
||||
# Note: generate_session_report uses self.session_buffer, so we might need to adapt it
|
||||
# or pass the buffer to it.
|
||||
# Let's check generate_session_report signature.
|
||||
report_text = await self.generate_session_report(advanced=True, custom_buffer=buffer)
|
||||
|
||||
# 1. Envoi au canal d'introspection
|
||||
await self.bot.introspection_log("FINAL VOICE SESSION ANALYSIS", report_text, discord.Color.blue())
|
||||
|
||||
# 2. Envoi direct aux Admins
|
||||
for admin_id in self.bot.admin_ids:
|
||||
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
|
||||
if admin:
|
||||
await self.bot.messaging.send_embed(admin, "FINAL VOICE SESSION ANALYSIS", report_text, color=discord.Color.blue())
|
||||
except Exception as e:
|
||||
logger.error(f"◈ SYSTEM: Background report generation failed: {e}")
|
||||
await self.bot.introspection_log(
|
||||
"VOICE TERMINATION",
|
||||
"Connexion vocale terminée. Libération des ressources audio.",
|
||||
discord.Color.dark_grey()
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue