Initialisation du repository de Beta

This commit is contained in:
Mathis 2026-02-06 22:23:20 +01:00
commit 14985f6dbb
9469 changed files with 1903273 additions and 0 deletions

6
superviseur/__init__.py Normal file
View file

@ -0,0 +1,6 @@
"""Superviseur Bot - Discord bot with LLM integration."""
from .bot import Superviseur
__all__ = ['Superviseur']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1303
superviseur/bot.py Normal file

File diff suppressed because it is too large Load diff

379
superviseur/commands.py Normal file
View file

@ -0,0 +1,379 @@
"""Command definitions for Superviseur bot."""
import discord
from discord.ext import commands
class ChannelCommands(commands.Cog):
"""Commands for channel management."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="create_channel")
async def cmd_create_channel(self, ctx, *args):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(args) == 0:
await ctx.send("Usage: !create_channel name1 [name2 ...] [category_name type]")
return
if len(args) >= 3 and args[-2].lower() in ['textuel', 'vocal', 'text', 'voice']:
channels = args[:-2]
category = args[-2]
type_ = args[-1]
elif len(args) >= 2 and args[-1].lower() in ['textuel', 'vocal', 'text', 'voice']:
channels = args[:-1]
category = None
type_ = args[-1].lower()
if type_ == 'text': type_ = 'textuel'
elif type_ == 'voice': type_ = 'vocal'
else:
channels = args
category = None
type_ = 'textuel'
params = {'channels': [{'channel_name': name, 'category_name': category, 'channel_type': type_} for name in channels]}
from commandes.salons.creer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="delete_channel")
async def cmd_delete_channel(self, ctx, *channels):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(channels) == 0:
await ctx.send("Usage: !delete_channel channel1 [channel2 ...]")
return
params = {'channels': [{'channel_name': name} for name in channels]}
from commandes.salons.supprimer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="move_channel")
async def cmd_move_channel(self, ctx, *args):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(args) % 2 != 0:
await ctx.send("Usage: !move_channel channel1 new_category [channel2 new_category ...]")
return
params = {'channels': [{'channel_name': args[i], 'new_category_name': args[i+1]} for i in range(0, len(args), 2)]}
from commandes.salons.deplacer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="modify_channel")
async def cmd_modify_channel(self, ctx, channel_name, new_name=None, new_category=None, topic=None, user_limit=None):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
params = {'channels': [{'channel_name': channel_name, 'new_name': new_name, 'new_category_name': new_category, 'topic': topic, 'user_limit': int(user_limit) if user_limit else None}]}
from commandes.salons.modifier import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="rename_channel")
async def cmd_rename_channel(self, ctx, *args):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(args) % 2 != 0:
await ctx.send("Usage: !rename_channel channel1 new_name [channel2 new_name ...]")
return
params = {'channels': [{'channel_name': args[i], 'new_name': args[i+1]} for i in range(0, len(args), 2)]}
from commandes.salons.renommer import execute
await execute(self.bot, params, ctx.message)
class CategoryCommands(commands.Cog):
"""Commands for category management."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="create_category")
async def cmd_create_category(self, ctx, *categories):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(categories) == 0:
await ctx.send("Usage: !create_category name1 [name2 ...]")
return
params = {'categories': [{'category_name': name} for name in categories]}
from commandes.categories.creer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="delete_category")
async def cmd_delete_category(self, ctx, *categories):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(categories) == 0:
await ctx.send("Usage: !delete_category name1 [name2 ...]")
return
params = {'categories': [{'category_name': name} for name in categories]}
from commandes.categories.supprimer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="modify_category")
async def cmd_modify_category(self, ctx, category_name, new_name=None):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if new_name is None:
await ctx.send("Usage: !modify_category category_name new_name")
return
params = {'categories': [{'category_name': category_name, 'new_name': new_name}]}
from commandes.categories.modifier import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="rename_category")
async def cmd_rename_category(self, ctx, *args):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(args) % 2 != 0:
await ctx.send("Usage: !rename_category category1 new_name [category2 new_name ...]")
return
params = {'categories': [{'category_name': args[i], 'new_name': args[i+1]} for i in range(0, len(args), 2)]}
from commandes.categories.renommer import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="move_category")
async def cmd_move_category(self, ctx, *args):
if not ctx.author.guild_permissions.manage_channels:
await ctx.send("Vous n'avez pas les permissions nécessaires.")
return
if len(args) % 2 != 0:
await ctx.send("Usage: !move_category category1 position [category2 position ...]")
return
try:
params = {'categories': [{'category_name': args[i], 'position': int(args[i+1])} for i in range(0, len(args), 2)]}
except ValueError:
await ctx.send("La position doit être un nombre entier.")
return
from commandes.categories.deplacer import execute
await execute(self.bot, params, ctx.message)
class SecurityCommands(commands.Cog):
"""Commands for security/moderation."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="kick")
async def cmd_kick(self, ctx, *users_mentions_or_ids):
if not ctx.author.guild_permissions.kick_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour expulser des membres.")
return
if len(users_mentions_or_ids) == 0:
await ctx.send("Usage: !kick @user1 @user2 ...")
return
params = {'users': [{'target_user_mention': user} for user in users_mentions_or_ids]}
from commandes.security.kick import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="ban")
async def cmd_ban(self, ctx, *users_mentions_or_ids):
if not ctx.author.guild_permissions.ban_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour bannir des membres.")
return
if len(users_mentions_or_ids) == 0:
await ctx.send("Usage: !ban @user1 @user2 ...")
return
params = {'users': [{'target_user_mention': user} for user in users_mentions_or_ids]}
from commandes.security.ban import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="unban")
async def cmd_unban(self, ctx, *users_mentions_or_ids):
if not ctx.author.guild_permissions.ban_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour débannir des membres.")
return
if len(users_mentions_or_ids) == 0:
await ctx.send("Usage: !unban user_id1 user_id2 ...")
return
params = {'users': [{'target_user_mention': user} for user in users_mentions_or_ids]}
from commandes.security.unban import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="mute")
async def cmd_mute(self, ctx, user_mention_or_id, duration_minutes: int, reason=None):
if not ctx.author.guild_permissions.moderate_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour muter des membres.")
return
params = {'users': [{'target_user_mention': user_mention_or_id, 'duration': duration_minutes, 'reason': reason or "Aucune raison spécifiée"}]}
from commandes.security.mute import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="timeout")
async def cmd_timeout(self, ctx, user_mention_or_id, duration_minutes: int, reason=None):
if not ctx.author.guild_permissions.moderate_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour appliquer un timeout.")
return
params = {'users': [{'target_user_mention': user_mention_or_id, 'duration': duration_minutes, 'reason': reason or "Aucune raison spécifiée"}]}
from commandes.security.timeout import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="purge")
async def cmd_purge(self, ctx, amount: int, channel_name=None):
if not ctx.author.guild_permissions.manage_messages:
await ctx.send("Vous n'avez pas les permissions nécessaires pour purger des messages.")
return
params = {'purges': [{'amount': amount, 'channel_name': channel_name or ctx.channel.name}]}
from commandes.security.purge import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="warn")
async def cmd_warn(self, ctx, user_mention_or_id, *, reason):
if not ctx.author.guild_permissions.moderate_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour avertir des membres.")
return
params = {'warnings': [{'target_user_mention': user_mention_or_id, 'reason': reason}]}
from commandes.security.warn import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="list_warnings")
async def cmd_list_warnings(self, ctx, user_mention_or_id=None):
if not ctx.author.guild_permissions.moderate_members:
await ctx.send("Vous n'avez pas les permissions nécessaires pour lister les avertissements.")
return
params = {'target_user_mention': user_mention_or_id}
from commandes.mod.list_warnings import execute
await execute(self.bot, params, ctx.message)
class OtherCommands(commands.Cog):
"""Miscellaneous commands."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="set_welcome_channel")
async def cmd_set_welcome_channel(self, ctx, channel_name):
if not ctx.author.guild_permissions.manage_guild:
await ctx.send("Vous n'avez pas les permissions nécessaires pour définir le salon de bienvenue.")
return
params = {'channel_name': channel_name}
from commandes.autres.setwelcomechannel import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="set_goodbye_channel")
async def cmd_set_goodbye_channel(self, ctx, channel_name):
if not ctx.author.guild_permissions.manage_guild:
await ctx.send("Vous n'avez pas les permissions nécessaires pour définir le salon d'au revoir.")
return
params = {'channel_name': channel_name}
from commandes.autres.setgoodbyechannel import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="set_mute_role")
async def cmd_set_mute_role(self, ctx, role_name):
if not ctx.author.guild_permissions.manage_roles:
await ctx.send("Vous n'avez pas les permissions nécessaires pour définir le rôle mute.")
return
params = {'role_name': role_name}
from commandes.autres.setmuterole import execute
await execute(self.bot, params, ctx.message)
@commands.command(name="send_message")
async def cmd_send_message(self, ctx, channel_name=None, dm_user=None, *, message_content):
if not ctx.author.guild_permissions.manage_messages:
await ctx.send("Vous n'avez pas les permissions nécessaires pour envoyer des messages via cette commande.")
return
if not channel_name and not dm_user:
await ctx.send("Usage: !send_message [channel_name] [dm_user] message_content")
return
params = {'channel_name': channel_name, 'dm_user': dm_user, 'message': message_content}
from commandes.autres.send_message import execute
await execute(self.bot, params, ctx.message)
class BêtaExpansionCommands(commands.Cog):
"""Expanded Bêta commands for Social Index and Voice."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="voice_diag")
async def cmd_voice_diag(self, ctx):
"""Diagnose tactical voice connectivity issues."""
if not (ctx.author.id in self.bot.admin_ids or self.bot.has_whitelist_permission(ctx.author.id, 'wl')):
return
guild = ctx.guild
me = guild.me
report = ["**◈ VOICE DIAGNOSTICS ◈**"]
# Check current state
active_vc = guild.voice_client
report.append(f"- Active Voice Client: `{'CONNECTED' if active_vc else 'IDLE'}`")
report.append(f"- Ear Protocol (Library): `{'READY' if self.bot.voice_manager.HAS_VOICE_RECV else 'MISSING'}`")
report.append(f"- Monitoring Status: `{'ACTIVE' if self.bot.voice_manager.is_monitoring else 'INACTIVE'}`")
if active_vc:
report.append(f"- Current Channel: `{active_vc.channel.name}`")
# Check permissions for all channels
report.append("\n**◈ CANAL PERMISSIONS ◈**")
for channel in guild.voice_channels:
perms = channel.permissions_for(me)
status = "" if (perms.connect and perms.view_channel) else ""
reason = []
if not perms.view_channel: reason.append("CANNOT VIEW")
if not perms.connect: reason.append("CANNOT CONNECT")
report.append(f"- `{channel.name}`: {status} {'(' + ', '.join(reason) + ')' if reason else ''}")
# Check Admin identity match
user_match = "MATCHED" if ctx.author.id in self.bot.admin_ids else "NOMINAL"
report.append(f"\n- Admin IDs (Config): `{self.bot.admin_ids}`")
report.append(f"- Your ID: `{ctx.author.id}` ({user_match})")
await self.bot.messaging.send_embed(
ctx.author,
"REPORT: TACTICAL CONNECTIVITY",
"\n".join(report),
color=discord.Color.blue(),
is_asset=True
)
@commands.command(name="voice_report")
async def cmd_voice_report(self, ctx, mode: str = None):
"""Get an instant clinical summary of the current voice session. Use 'adv' for AI analysis."""
if not (ctx.author.id in self.bot.admin_ids or self.bot.has_whitelist_permission(ctx.author.id, 'wl')):
return
if not self.bot.voice_manager.is_monitoring:
await ctx.send("◈ SYSTEM: Aucune session de surveillance vocale active actuellement.")
return
is_advanced = mode and (mode.lower() in ["adv", "advanced", "full", "ai", "llm"])
if is_advanced:
await ctx.send("◈ SYSTEM: Lancement de l'analyse heuristique profonde (Gpt-Oss)...")
async with ctx.typing():
report = await self.bot.voice_manager.generate_session_report(advanced=True)
else:
report = await self.bot.voice_manager.generate_session_report(advanced=False)
await self.bot.messaging.send_embed(
ctx.author,
"INSTANT VOICE SESSION REPORT",
report,
color=discord.Color.blue(),
is_asset=True
)
async def setup_commands(bot):
"""Register all commands with the bot."""
# Channel commands
await bot.add_cog(ChannelCommands(bot))
# Category commands
await bot.add_cog(CategoryCommands(bot))
# Security commands
await bot.add_cog(SecurityCommands(bot))
# Other commands
await bot.add_cog(OtherCommands(bot))
# Bêta Expansion commands
await bot.add_cog(BêtaExpansionCommands(bot))

89
superviseur/context.py Normal file
View file

@ -0,0 +1,89 @@
"""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"
# Recent activity (approximate)
recent_members = sorted(guild.members, key=lambda m: m.joined_at or m.created_at, reverse=True)[:3]
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."""
if not guild:
return ""
lines = ["\nListe des salons:"]
for ch in guild.channels:
if isinstance(ch, discord.TextChannel):
ch_type = 'textuel'
elif isinstance(ch, discord.VoiceChannel):
ch_type = 'vocal'
else:
ch_type = str(ch.type)
lines.append(f"- {ch.name} ({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
superviseur/dispatcher.py Normal file
View 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__), "..", "assets_config.json")
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "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 = {
"model": self.bot.ollama_model_fast,
"prompt": prompt,
"stream": False
}
resp = await self.bot.llm.call_ollama(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)

46
superviseur/heuristic.py Normal file
View file

@ -0,0 +1,46 @@
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
# 1. Social Score Factor (Base scrutiny)
# If user is Suspect (<40), we start with higher risk score
social_data = self.social_manager.get_user_data(message.author.id)
reliability = social_data.get('score', 50)
if reliability < 30: score += 40 # Very suspicious user -> almost auto-check
elif reliability < 50: score += 20
# 2. Pattern Matching (Fast Regex/Keywords)
content_lower = content.lower()
# CAPS LOCK detection (> 60% caps on long messages)
if len(content) > 10:
caps_ratio = sum(1 for c in content if c.isupper()) / len(content)
if caps_ratio > 0.6: score += 30
# 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"]
for t in triggers:
if t in content_lower:
score += 50
break
# Link spam detection
if "http" in content_lower:
score += 15
# Length anomaly (very long messages often rants)
if len(content) > 400:
score += 20
# 3. Mention Spam
if len(message.mentions) > 3:
score += 40
# Threshold decision
# If score > 45, we analyze.
return score >= 45

162
superviseur/llm.py Normal file
View file

@ -0,0 +1,162 @@
"""LLM integration for Superviseur bot."""
import json
import aiohttp
import logging
import re
from typing import Optional, Dict, Any
logger = logging.getLogger('Superviseur')
class LLMManager:
"""Manages LLM interactions with Ollama."""
def __init__(
self,
ollama_api_url: str,
ollama_model: str,
ollama_timeout: int = 60,
ollama_temperature: float = 0.7
):
self.ollama_api_url = ollama_api_url
self.ollama_model = ollama_model
self.ollama_timeout = ollama_timeout
self.ollama_temperature = ollama_temperature
self.http_session: Optional[aiohttp.ClientSession] = None
async def get_session(self) -> aiohttp.ClientSession:
"""Get or create HTTP session."""
if not self.http_session or self.http_session.closed:
self.http_session = aiohttp.ClientSession()
return self.http_session
async def close_session(self) -> None:
"""Close HTTP session."""
if self.http_session and not self.http_session.closed:
await self.http_session.close()
def build_payload(
self,
prompt: str,
system_prompt: Optional[str] = None,
vision_model: bool = False,
attachments: Optional[list] = None
) -> Dict[str, Any]:
"""Build the API payload for Ollama."""
payload = {
"model": self.ollama_model,
"prompt": prompt,
"stream": True,
"options": {"temperature": self.ollama_temperature}
}
if system_prompt:
payload["system"] = system_prompt
# Vision support (simplified)
if vision_model and attachments:
for attachment in attachments:
if attachment.content_type and attachment.content_type.startswith('image/'):
if any(v in self.ollama_model.lower() for v in
['llava', 'bakllava', 'moondream', 'llama3.2-vision', 'minicpm-v']):
# Note: actual image handling would require download
pass
return payload
async def call_ollama(self, payload: Dict[str, Any]) -> str:
"""Call Ollama API and accumulate response."""
session = await self.get_session()
async with session.post(
self.ollama_api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=self.ollama_timeout)
) as response:
response.raise_for_status()
# Si on ne streame pas, on récupère tout d'un coup
if not payload.get("stream", True):
data = await response.json()
return data.get("response", "")
accumulated_reply = ""
async for line in response.content:
line = line.decode('utf-8').strip()
if not line:
continue
try:
data = json.loads(line)
if 'thinking' in data and data['thinking']:
print(f"🤔 Le superviseur réfléchit : {data['thinking']}", flush=True)
continue
if 'response' in data:
chunk = data['response']
accumulated_reply += chunk
if chunk.strip():
print(f"🤖 Réponse du Superviseur (fragment) : {chunk}", flush=True)
elif 'action' in data:
accumulated_reply = line
break
except json.JSONDecodeError:
accumulated_reply += line
return accumulated_reply
def extract_json_actions(self, text: str) -> Optional[str]:
"""Extract JSON action objects from text more robustly."""
actions = []
# Regex plus laxiste pour trouver des blocs JSON potentiels
# On cherche des blocs commençant par {"action" ou {'action'
matches = re.finditer(r'\{[\s\n]*[\"\']action[\"\'].*?\}', text, re.DOTALL)
for match in matches:
json_str = match.group(0)
try:
# Tentative de parsing standard
parsed = json.loads(json_str)
if isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
except json.JSONDecodeError:
# Si erreur, on tente de remplacer les simples quotes par des doubles
# (Cas fréquent si le LLM mélange les styles)
try:
# Remplacement très basique (peut échouer si contenu complexe)
json_corrected = json_str.replace("'", '"')
parsed = json.loads(json_corrected)
if isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
continue
except: pass
# Méthode récursive des accolades si le bloc est tronqué ou complexe
start = match.start()
brace_count = 0
for i in range(start, len(text)):
if text[i] == '{': brace_count += 1
elif text[i] == '}':
brace_count -= 1
if brace_count == 0:
try:
candidate = text[start:i+1]
try:
parsed = json.loads(candidate)
except:
parsed = json.loads(candidate.replace("'", '"'))
if isinstance(parsed, dict) and 'action' in parsed:
actions.append(parsed)
break
except: pass
if actions:
return json.dumps(actions)
return None

134
superviseur/messaging.py Normal file
View file

@ -0,0 +1,134 @@
"""Messaging utilities for Superviseur bot."""
import discord
import logging
from typing import Optional, List, Dict
logger = logging.getLogger('Superviseur')
class MessagingManager:
"""Manages message sending with rate limiting and chunking."""
def __init__(self, bot):
self.bot = bot
async def send_message_with_limit(
self,
channel: discord.abc.Messageable,
content: str
) -> None:
"""Send a message, splitting into multiple if it exceeds 2000 chars."""
while len(content) > 2000:
split_index = content.rfind(' ', 0, 2000)
if split_index == -1:
split_index = 2000
chunk = content[:split_index]
await channel.send(chunk)
content = content[split_index:].lstrip()
if content:
await channel.send(content)
async def reply_with_limit(
self,
message: discord.Message,
content: str
) -> None:
"""Reply to a message, splitting if it exceeds 2000 chars."""
if not message.channel:
logger.warning(f"Cannot send message: no channel")
return
if hasattr(message.channel, 'permissions_for'):
perms = message.channel.permissions_for(
message.guild.me if message.guild else message.author
)
if not perms.send_messages:
logger.warning(f"Cannot send message in channel {message.channel}")
return
first_chunk = True
while len(content) > 2000:
split_index = content.rfind(' ', 0, 2000)
if split_index == -1:
split_index = 2000
chunk = content[:split_index]
if first_chunk:
try:
await message.reply(chunk)
except (discord.errors.HTTPException, discord.errors.NotFound):
try:
await message.channel.send(chunk)
except discord.errors.Forbidden:
logger.error(f"Forbidden to send in channel {message.channel}")
first_chunk = False
else:
try:
await message.channel.send(chunk)
except discord.errors.Forbidden:
logger.error(f"Forbidden to send in channel {message.channel}")
return
content = content[split_index:].lstrip()
if content:
if first_chunk:
try:
await message.reply(content)
except (discord.errors.HTTPException, discord.errors.NotFound):
try:
await message.channel.send(content)
except discord.errors.Forbidden:
logger.error(f"Forbidden to send in channel {message.channel}")
else:
try:
await message.channel.send(content)
except discord.errors.Forbidden:
logger.error(f"Forbidden to send in channel {message.channel}")
async def send_embed(
self,
target: discord.abc.Messageable,
title: str,
description: str,
color: discord.Color = discord.Color.light_grey(),
thumbnail_url: Optional[str] = None,
is_asset: bool = False,
fields: Optional[List[Dict[str, str]]] = None
) -> None:
"""Send a clinical Samaritan-style embed with support for fields and clickable links."""
# Clinical formatting - we avoid the global ``` to allow markdown (links, mentions)
display_title = f"{title}"
embed = discord.Embed(
title=display_title,
description=description,
color=color
)
if fields:
for field in fields:
embed.add_field(
name=field.get("name", "---"),
value=field.get("value", "---"),
inline=field.get("inline", True)
)
if is_asset:
embed.set_author(
name="SYSTEM: BÊTA | PRIMARY ASSET LINK",
icon_url="https://i.imgur.com/xH3X5rR.png"
)
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
embed.set_footer(text=f"HEURISTIC ANALYSIS COMPLETE | {self.bot.system_name} v2.0")
try:
await target.send(embed=embed)
except discord.errors.Forbidden:
logger.error(f"Cannot send DM/Message to {target}")

103
superviseur/permissions.py Normal file
View file

@ -0,0 +1,103 @@
"""Permissions and whitelist handling for Superviseur bot."""
import json
import logging
from typing import Dict, Optional
logger = logging.getLogger('Superviseur')
class WhitelistManager:
"""Manages user whitelist and permissions."""
def __init__(self, whitelist_file: str = 'whitelist.json'):
self.whitelist_file = whitelist_file
self.whitelist = None
self.load_whitelist()
def load_whitelist(self) -> None:
"""Load whitelist from JSON file."""
try:
with open(self.whitelist_file, 'r', encoding='utf-8') as f:
self.whitelist = json.load(f)
logger.info("Whitelist loaded successfully")
except FileNotFoundError:
# Supprimer le message de warning pour whitelist.json
self.whitelist = None
except json.JSONDecodeError as e:
logger.error(f"Error parsing whitelist.json: {e}")
self.whitelist = None
def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]:
"""Get user's whitelist level by ID or name."""
if not self.whitelist:
return None
user_levels = self.whitelist.get('user_levels', {})
# Try by ID first
level = user_levels.get(str(user_id))
if level:
return level
# Try by name if provided
if user_name:
level = user_levels.get(user_name)
if level:
return level
return None
def has_permission(self, user_id: int, required_level: str, user_name: Optional[str] = None) -> bool:
"""Check if user has required whitelist level."""
level = self.get_user_level(user_id, user_name)
if not level:
return False
levels = ['wl', 'wl+']
try:
user_index = levels.index(level)
req_index = levels.index(required_level)
return user_index >= req_index
except ValueError:
return False
def get_required_level(self, action: str) -> Optional[str]:
"""Get required whitelist level for an action."""
if not self.whitelist:
return None
return self.whitelist.get('commands_permissions', {}).get(action)
def check_is_admin(permissions) -> bool:
"""Check if user has admin permissions."""
if not permissions:
return False
return (
permissions.administrator or
permissions.manage_guild or
permissions.manage_roles or
permissions.manage_channels or
permissions.kick_members or
permissions.ban_members or
permissions.moderate_members
)
def get_permissions_info(permissions, user_level: str = 'none', role_name: str = 'Sujet') -> str:
"""Get formatted permissions info string including role awareness."""
if not permissions:
return f"RÔLE: {role_name}, Permissions (DM): non, Niveau whitelist: {user_level}"
has_permissions = (
permissions.administrator or
permissions.manage_guild or
permissions.manage_roles or
permissions.manage_channels or
permissions.kick_members or
permissions.ban_members or
permissions.moderate_members or
permissions.manage_messages
)
return f"RÔLE: {role_name}, Permissions: {'oui' if has_permissions else 'non'}, Niveau whitelist: {user_level}"

View file

@ -0,0 +1,98 @@
"""Redis worker support for Superviseur bot."""
import json
import uuid
import asyncio
import logging
from typing import Dict, Optional, Any
logger = logging.getLogger('Superviseur')
class RedisWorker:
"""Manages Redis-based LLM job queue."""
def __init__(self, bot):
self.bot = bot
self.redis_client = None
self._pending_jobs: Dict[str, asyncio.Future] = {}
self._listener_task: Optional[asyncio.Task] = None
self._response_queue = 'llm_queue:responses'
self._job_queue = 'llm_queue:jobs'
async def initialize(self, redis_url: str) -> bool:
"""Initialize Redis connection."""
if not self.bot.redis_available:
return False
try:
self.redis_client = self.bot.aioredis.from_url(redis_url)
self._listener_task = asyncio.create_task(self._listen())
logger.info("Redis LLM worker enabled; listening for responses")
return True
except Exception:
logger.exception("Failed to initialize redis client")
return False
async def close(self) -> None:
"""Close Redis connection."""
if self._listener_task:
try:
self._listener_task.cancel()
except Exception:
pass
if self.redis_client:
try:
await self.redis_client.close()
except Exception:
pass
async def enqueue_job(self, payload: Dict[str, Any], timeout: int = 60) -> Optional[Dict[str, Any]]:
"""Enqueue an LLM job and wait for result."""
if not self.redis_client:
return None
job_id = str(uuid.uuid4())
future = self.bot.loop.create_future()
self._pending_jobs[job_id] = future
payload['job_id'] = job_id
await self.redis_client.lpush(self._job_queue, json.dumps(payload))
logger.debug(f"LLM job {job_id} enqueued.")
try:
return await asyncio.wait_for(future, timeout=timeout)
finally:
self._pending_jobs.pop(job_id, None)
async def _listen(self) -> None:
"""Listen for Redis responses."""
logger.info(f"Starting Redis response listener on {self._response_queue}")
while True:
try:
result = await self.redis_client.brpop(self._response_queue, timeout=1)
if result is None:
await asyncio.sleep(0.1)
continue
data_bytes = result[1]
response = json.loads(data_bytes.decode('utf-8'))
job_id = response.get('job_id')
if job_id in self._pending_jobs:
future = self._pending_jobs[job_id]
if not future.done():
future.set_result(response)
logger.debug(f"LLM job {job_id} completed via Redis.")
else:
logger.warning(f"Response for unknown job {job_id}")
except asyncio.CancelledError:
logger.info("Redis listener cancelled")
break
except Exception as e:
logger.exception(f"Redis listener error: {e}")
await asyncio.sleep(5)

16
superviseur/utils.py Normal file
View file

@ -0,0 +1,16 @@
"""Utility functions for Superviseur bot."""
import re
def format_mentions_in_text(text: str) -> str:
"""Convert user ID numbers in text to Discord mention format <@id>."""
# Match sequences of 15-20 digits
pattern = r'\b(\d{15,20})\b'
return re.sub(pattern, r'<@\1>', text)
def sanitize_guild_name(name: str) -> str:
"""Sanitize guild name for use in file paths."""
return re.sub(r'[\/\\:*?"<>|\s]', '_', name)

354
superviseur/voice.py Normal file
View file

@ -0,0 +1,354 @@
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 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 = {
"model": self.bot.ollama_model,
"prompt": prompt,
"system": self.bot.system_prompt,
"stream": False
}
# Note: call_ollama returns the accumulated string
analysis = await self.bot.llm.call_ollama(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()
)

View file

@ -0,0 +1,58 @@
import os
import logging
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
logger = logging.getLogger('Superviseur')
class WhisperWorker:
"""Handles local STT transcription using faster-whisper."""
def __init__(self, model_size="large-v3", device="cpu"):
self.model_size = model_size
self.device = device
self.model = None
self.executor = ThreadPoolExecutor(max_workers=2)
self.initialized = False
def initialize(self):
"""Load the model into RAM/VRAM."""
try:
from faster_whisper import WhisperModel
logger.info(f"◈ SYSTEM: Initializing Whisper ({self.model_size}) on {self.device}...")
# On tente le chargement. Si device="cuda" échoue (AMD), on bascule sur CPU.
try:
self.model = WhisperModel(self.model_size, device=self.device, compute_type="int8")
except Exception as e:
if self.device != "cpu":
logger.warning(f"◈ GPU failure ({e}). Falling back to CPU.")
self.device = "cpu"
self.model = WhisperModel(self.model_size, device="cpu", compute_type="int8")
else:
raise e
self.initialized = True
logger.info(f"◈ SYSTEM: Whisper Model Loaded on {self.device}. Ear Protocol Active.")
except Exception as e:
logger.error(f"Failed to initialize Whisper: {e}")
self.initialized = False
async def transcribe(self, audio_path: str) -> Optional[str]:
"""Transcribe an audio file asynchronously."""
if not self.initialized or not self.model:
return None
def _run():
try:
segments, info = self.model.transcribe(audio_path, beam_size=5)
text = " ".join([segment.text for segment in segments]).strip()
logger.debug(f"◈ Whisper: Transcription result: '{text}' (lang: {info.language})")
return text
except Exception as e:
logger.error(f"Transcription error: {e}")
return None
loop = asyncio.get_event_loop()
return await loop.run_in_executor(self.executor, _run)