Initialisation du repository de Beta
This commit is contained in:
commit
14985f6dbb
9469 changed files with 1903273 additions and 0 deletions
6
superviseur/__init__.py
Normal file
6
superviseur/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Superviseur Bot - Discord bot with LLM integration."""
|
||||
|
||||
from .bot import Superviseur
|
||||
|
||||
__all__ = ['Superviseur']
|
||||
|
||||
BIN
superviseur/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/bot.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/bot.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/commands.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/commands.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/context.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/context.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/dispatcher.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/dispatcher.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/llm.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/llm.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/messaging.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/messaging.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/permissions.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/permissions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/redis_worker.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/redis_worker.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/social.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/social.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/utils.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/voice.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/voice.cpython-312.pyc
Normal file
Binary file not shown.
BIN
superviseur/__pycache__/whisper_worker.cpython-312.pyc
Normal file
BIN
superviseur/__pycache__/whisper_worker.cpython-312.pyc
Normal file
Binary file not shown.
1303
superviseur/bot.py
Normal file
1303
superviseur/bot.py
Normal file
File diff suppressed because it is too large
Load diff
379
superviseur/commands.py
Normal file
379
superviseur/commands.py
Normal 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
89
superviseur/context.py
Normal 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
234
superviseur/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__), "..", "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
46
superviseur/heuristic.py
Normal 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
162
superviseur/llm.py
Normal 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
134
superviseur/messaging.py
Normal 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
103
superviseur/permissions.py
Normal 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}"
|
||||
|
||||
98
superviseur/redis_worker.py
Normal file
98
superviseur/redis_worker.py
Normal 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
16
superviseur/utils.py
Normal 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
354
superviseur/voice.py
Normal 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()
|
||||
)
|
||||
58
superviseur/whisper_worker.py
Normal file
58
superviseur/whisper_worker.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue