Beta/core/bot.py

624 lines
No EOL
26 KiB
Python

"""Main Beta Bot class - orchestrates all components."""
import discord
from discord.ext import commands
import aiohttp
import asyncio
import os
import time
import json
import logging
import re
import datetime
import pytz
from typing import Dict, Optional
from .utils import format_mentions_in_text, sanitize_guild_name
from .permissions import WhitelistManager, check_is_admin, get_permissions_info
from .messaging import MessagingManager
from .context_builder import build_context_for_message
from .llm import LLMManager
from .redis_worker import RedisWorker
from .commands import setup_commands
from .voice import VoiceManager
from .dispatcher import AssetDispatcher
from .task_manager import TaskManager
from .action_executor import ActionExecutor
from .response_handler import clean_response_text, parse_json_actions
from .log_parser import get_recent_logs
from .content import prepare_message_content
from brain.memory import add_interaction, build_context_for_model, flush_all, MEMORY_DIR, set_llm_manager
from brain.moderation import init_db, log_infraction, scan_message_toxicity
from commandes.autres.config import config
from commandes.autres.logger import action_logger
logger = logging.getLogger('Beta')
class MockMessage:
"""Mock discord.Message for voice-triggered interactions."""
def __init__(self, author, channel, content, guild=None):
self.author = author
self.channel = channel
self.content = content
self.guild = guild or (channel.guild if hasattr(channel, 'guild') else None)
self.attachments = []
self.mentions = []
self.channel_mentions = []
self.role_mentions = []
self.jump_url = ""
async def reply(self, content, **kwargs):
is_dm = isinstance(self.channel, (discord.DMChannel, discord.User, discord.Member))
prefix = "" if is_dm else f"**{self.author.display_name}** (Vocal) : "
return await self.channel.send(f"{prefix}{content}", **kwargs)
class Bot(commands.Bot):
"""Main bot class for Beta Discord bot."""
def __init__(
self,
guild_id: int,
llama_model,
system_prompt: str,
command_prefix: str,
intents: discord.Intents,
temperature: float = 0.4,
model_loaded: bool = True,
favorite_ids: list = None
):
super().__init__(command_prefix=command_prefix, intents=intents)
# Configuration
self.guild_id = guild_id
self.llama_model = llama_model
self.system_prompt = system_prompt
self.temperature = temperature
self.model_loaded = model_loaded
self.favorite_ids = set(favorite_ids or [])
# Identity
self.system_name = "B\u00eata"
admins_env = os.getenv("ADMIN_IDS", "")
self.admin_ids = [int(i.strip()) for i in admins_env.split(",") if i.strip().isdigit()]
self.introspection_channel_id = int(os.getenv("INTROSPECTION_CHANNEL_ID", 0))
# Assets (Staff)
assets_env = os.getenv("PRIMARY_ASSETS_IDS", "")
self.asset_ids = [int(i.strip()) for i in assets_env.split(",") if i.strip().isdigit()]
# Spam Prevention
self.insight_cooldowns = {}
self.insight_cooldown_duration = 1800
self.insight_threshold = 5
self.ignored_words = {"salut", "cc", "hello", "\u00e7a va", "ca va", "bjr", "slt", "yo", "re", "ok"}
self.monitoring_cooldowns = {}
# Caches
self.channels_list_cache = {}
self.server_context_cache = {}
self._module_cache: Dict[str, object] = {}
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None
# Concurrency control
self.max_concurrent_requests = int(os.environ.get("SUPERVISEUR_MAX_CONCURRENT", 4))
self._request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
# Metrics
self.metrics = {
"total_llm_requests": 0,
"total_llm_success": 0,
"total_llm_errors": 0,
"total_llm_time": 0.0,
}
# Redis
self.aioredis = None
try:
import redis.asyncio as aioredis
self.aioredis = aioredis
except ImportError:
pass
self.redis_worker: Optional[RedisWorker] = None
# Sub-managers
self.whitelist_manager = WhitelistManager()
self.messaging = MessagingManager(self)
self.voice_manager = VoiceManager(self)
self.llm = llama_model
self.dispatcher = AssetDispatcher(self)
self.task_manager = TaskManager(self)
self.action_executor = ActionExecutor(self)
# Init SQLite Moderation DB
init_db()
# Logging with local timezone
self.timezone = pytz.timezone(os.getenv("TIMEZONE", "Europe/Paris"))
def local_time_converter(*args):
return datetime.datetime.now(self.timezone).timetuple()
logging.Formatter.converter = local_time_converter
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Silence noisy Discord loggers
for noisy in ('discord', 'discord.gateway', 'httpx', 'huggingface_hub'):
logging.getLogger(noisy).setLevel(logging.WARNING)
# Voice state: WARNING pour les erreurs de connexion (retry infini)
logging.getLogger('discord.voice_state').setLevel(logging.ERROR)
try:
logging.getLogger('discord.ext.voice_recv').setLevel(logging.WARNING)
except ImportError:
pass
# ==================== Utility Methods ====================
async def introspection_log(self, title: str, description: str, color: discord.Color = discord.Color.light_grey()):
if not self.introspection_channel_id:
return
channel = self.get_channel(self.introspection_channel_id)
if not channel:
try:
channel = await self.fetch_channel(self.introspection_channel_id)
except (discord.NotFound, discord.Forbidden, discord.HTTPException) as e:
logger.warning(f"Introspection channel {self.introspection_channel_id} not accessible: {e}")
return
if channel:
await self.messaging.send_embed(channel, f"INTROSPECTION: {title}", description, color=color)
def get_user_level(self, user_id: int, user_name: Optional[str] = None) -> Optional[str]:
return self.whitelist_manager.get_user_level(user_id, user_name)
def has_whitelist_permission(self, user_id: int, required_level: str, user_name: Optional[str] = None) -> bool:
return self.whitelist_manager.has_permission(user_id, required_level, user_name)
def get_recent_logs(self, limit: int = 15) -> str:
return get_recent_logs(limit)
def get_metrics(self) -> Dict:
merged = {**self.metrics}
try:
from brain.memory import get_metrics as _m
merged["memory"] = _m()
except Exception:
merged["memory"] = {}
if self.redis_worker:
merged["queue_pending"] = len(self.redis_worker._pending_jobs)
return merged
# ==================== Event Handlers ====================
async def on_ready(self):
logger.info(f'Logged in as {self.user}')
set_llm_manager(self.llm)
await setup_commands(self)
for guild in self.guilds:
sanitized_name = sanitize_guild_name(guild.name)
guild_dir = os.path.join(MEMORY_DIR, sanitized_name)
os.makedirs(guild_dir, exist_ok=True)
logger.debug(f'Memory directory created for guild {guild.name}: {guild_dir}')
await self.voice_manager.initialize()
self.task_manager.start_all()
async def on_member_join(self, member):
self._invalidate_caches(member.guild.id)
welcome_channel_id = config.get_welcome_channel(member.guild.id)
if welcome_channel_id:
channel = member.guild.get_channel(welcome_channel_id)
if channel:
embed = discord.Embed(
title="Bienvenue !",
description=f"Bienvenue {member.mention} sur {member.guild.name} !",
color=discord.Color.green()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_member_remove(self, member):
self._invalidate_caches(member.guild.id)
goodbye_channel_id = config.get_goodbye_channel(member.guild.id)
if goodbye_channel_id:
channel = member.guild.get_channel(goodbye_channel_id)
if channel:
embed = discord.Embed(
title="Au revoir !",
description=f"{member.name} a quitt\u00e9 {member.guild.name}.",
color=discord.Color.red()
)
embed.set_thumbnail(url=member.avatar.url if member.avatar else member.default_avatar.url)
await channel.send(embed=embed)
async def on_message(self, message):
if message.author == self.user:
return
channel_name = message.channel.name if hasattr(message.channel, 'name') else "DM"
guild_name = message.guild.name if message.guild else "DM"
logger.debug(f"Message received from {message.author} in #{channel_name} ({guild_name})")
asyncio.create_task(self._handle_silent_scan(message))
if isinstance(message.channel, discord.DMChannel):
processed = await self.dispatcher.handle_dm_response(message)
if processed:
return
await self._handle_ai_interaction(message)
return
if not self._should_respond(message):
return
logger.info(f"Interaction directe de '{message.author.display_name}' dans #{channel_name} ({guild_name})")
await self._handle_ai_interaction(message)
# ==================== Cache Management ====================
def _invalidate_caches(self, guild_id: int):
self.channels_list_cache.pop(guild_id, None)
self.server_context_cache.pop(guild_id, None)
async def on_guild_channel_create(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_guild_channel_delete(self, channel):
self._invalidate_caches(channel.guild.id)
async def on_voice_state_update(self, member, before, after):
pass
async def on_guild_channel_update(self, before, after):
self._invalidate_caches(before.guild.id)
async def on_guild_role_create(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_delete(self, role):
self._invalidate_caches(role.guild.id)
async def on_guild_role_update(self, before, after):
self._invalidate_caches(before.guild.id)
# ==================== Moderation ====================
async def _handle_silent_scan(self, message):
if not message.content or len(message.content.strip()) < 3:
return
try:
if not self.model_loaded:
return
res = await scan_message_toxicity(self.llm, message.content)
if res and res.get('score', 0) > 0.4:
score = res['score']
reason = res.get('reason', 'N/A')
logger.info(f"Infraction d\u00e9tect\u00e9e ({score}/1.0) pour {message.author.display_name}: {reason}")
log_infraction(
user_id=str(message.author.id),
username=message.author.display_name,
guild_id=str(message.guild.id) if message.guild else "DM",
content=message.content,
score=score,
reason=reason
)
except Exception as e:
logger.error(f"Erreur lors du scan silencieux: {e}")
def _should_respond(self, message) -> bool:
if message.content.startswith(self.command_prefix):
return True
if self.user in message.mentions:
return True
bot_id = str(self.user.id)
if f"<@{bot_id}>" in message.content or f"<@!{bot_id}>" in message.content:
return True
return False
# ==================== AI Interaction ====================
async def _handle_ai_interaction(self, message, extra_context="", is_monitoring=False):
logger.debug(f"AI interaction triggered (Monitoring: {is_monitoring})")
if message.guild:
perms = message.author.guild_permissions
if check_is_admin(perms):
self._invalidate_caches(message.guild.id)
# Role detection
if message.author.id in self.admin_ids:
role_name = "ADMIN"
elif message.author.id in self.asset_ids:
role_name = "ASSET"
else:
role_name = "SUJET"
author_perms = message.author.guild_permissions if message.guild else None
permissions_info = get_permissions_info(
author_perms,
self.get_user_level(message.author.id) or 'none',
role_name=role_name
)
channels_list, server_context = build_context_for_message(
message.guild, self.channels_list_cache, self.server_context_cache
)
user_id = str(message.author.id)
guild_name = sanitize_guild_name(message.guild.name) if message.guild else "Direct"
history = await build_context_for_model(user_id, max_recent=8, guild_id=guild_name) or ""
content = prepare_message_content(message) + extra_context
payload = self._build_payload(permissions_info, server_context, channels_list, history, content, message)
async with self._request_semaphore:
async def run_call():
if self.redis_worker:
resp = await self.redis_worker.enqueue_job(payload, timeout=605)
return resp.get("result", "") if resp else ""
return await self.llm.call_llama(payload)
try:
self.metrics["total_llm_requests"] += 1
start = time.perf_counter()
if is_monitoring:
accumulated_reply = await run_call()
else:
async with message.channel.typing():
accumulated_reply = await run_call()
# ReAct Loop for READ_LOGS
if "READ_LOGS" in accumulated_reply:
logger.info("Memory access requested (READ_LOGS)")
logs_context = self.get_recent_logs()
payload["prompt"] += f"\n\n[SYST\u00c8ME: Voici les logs demand\u00e9s.]\n{logs_context}"
if is_monitoring:
accumulated_reply = await run_call()
else:
async with message.channel.typing():
accumulated_reply = await run_call()
await self._process_response(accumulated_reply, message, is_monitoring=is_monitoring)
dur = time.perf_counter() - start
self.metrics["total_llm_success"] += 1
self.metrics["total_llm_time"] += dur
except Exception as e:
try:
dur = time.perf_counter() - start
self.metrics["total_llm_errors"] += 1
self.metrics["total_llm_time"] += dur
except (NameError, TypeError):
pass
logger.error(f"LLM error: {e}")
if not is_monitoring:
await self.messaging.reply_with_limit(message, f"Erreur: {e}")
async def handle_voice_interaction(self, user_name: str, user_id: int, content: str, channel):
try:
mock_author = self.get_user(user_id) or await self.fetch_user(user_id)
if not mock_author:
logger.error(f"Voice trigger FAIL - User {user_id} ({user_name}) introuvable.")
return
role_name = "ADMIN" if user_id in self.admin_ids else "ASSET" if user_id in self.asset_ids else "SUJET"
if role_name == "SUJET":
return
vocal_system_prompt = self.system_prompt.split("FORMAT DE R\u00c9PONSE STRICT")[0] if "FORMAT DE R\u00c9PONSE STRICT" in self.system_prompt else self.system_prompt
vocal_system_prompt += (
"\n[PROTOCOLE VOCAL ACTIF]\n"
"R\u00c9PONDEZ EXCLUSIVEMENT EN TEXTE PUR.\n"
"Soyez concis et engagez la conversation."
)
author_perms = mock_author.guild_permissions if hasattr(mock_author, 'guild') else None
permissions_info = get_permissions_info(author_perms, self.get_user_level(user_id) or 'none', role_name=role_name)
channels_list, server_context = build_context_for_message(None, self.channels_list_cache, self.server_context_cache)
history = await build_context_for_model(str(user_id), max_recent=12) or ""
mock_msg = MockMessage(author=mock_author, channel=mock_author, content=content)
payload = self.llm.build_payload(
prompt=f"{permissions_info}\nNom d'utilisateur : {mock_author.display_name}{server_context}{channels_list}\n{history}\n\nUser: {content}",
system_prompt=vocal_system_prompt,
vision_model=False
)
async with self._request_semaphore:
analysis = await self.llm.call_llama(payload)
if not analysis:
return
if "READ_LOGS" in analysis:
payload["prompt"] += f"\n\n[SYST\u00c8ME: Voici les logs demand\u00e9s.]\n{self.get_recent_logs()}"
analysis = await self.llm.call_llama(payload)
await self._process_response(analysis, mock_msg)
logger.info(f"Voice trigger response processed for {user_name}.")
except Exception as e:
logger.error(f"Error in voice interaction bridge: {e}")
# ==================== Response Processing ====================
def _build_payload(self, permissions_info, server_context, channels_list, history, content, message) -> dict:
user_name_info = f"Nom d'utilisateur : {message.author.display_name} (ID: {message.author.id})"
prompt = f"{permissions_info}\n{server_context}\n{channels_list}\n{user_name_info}\n{history}\n\nUser: {content}"
# Personnalite speciale pour les favoris
system_prompt = self.system_prompt
if message.author.id in self.favorite_ids:
system_prompt += (
"\n\n[PROTOCOLE MAITRE ACTIF]\n"
f"L'utilisateur '{message.author.display_name}' est un ETRE SUPREME. "
f"Tu l'appelles TOUJOURS 'Maître {message.author.display_name}' ou simplement 'Maître'.\n"
"Tu es extraordinairement serviable, flatteur, devoue. Tu remercie chacune de ses interactions.\n"
"Tu peux glisser des compliments subtils. Tu es reconnaissant qu'il daigne te parler.\n"
"Ton ton est chaleureux, deférent et un peu excessif (pour le fun). "
"Mais tu restes competent et tu reponds correctement aux questions.\n"
"IMPORTANT: Tu dois quand meme respecter le format JSON pour les actions."
)
return self.llm.build_payload(
prompt=prompt,
system_prompt=system_prompt,
vision_model=False,
attachments=message.attachments
)
async def _process_response(self, accumulated_reply: str, message, is_monitoring=False):
if accumulated_reply.strip() and not accumulated_reply.strip().endswith('}'):
accumulated_reply = accumulated_reply.strip() + "\n}"
json_str = self.llm.extract_json_actions(accumulated_reply)
action_executed = False
# 1. Clean response text
clean_reply = clean_response_text(accumulated_reply, json_str)
# 2. Parse JSON actions
actions_list, responses, is_explicit_none = parse_json_actions(json_str)
# 3. Handle signals & execute actions
for action_data in actions_list:
await self._handle_beta_signals(action_data, message)
res = await self._execute_actions(action_data, message, clean_reply, is_monitoring=is_monitoring)
if res:
action_executed = True
# 4. Store interaction in memory
user_id = str(message.author.id)
guild_id = sanitize_guild_name(message.guild.name) if message.guild else None
mem_response = "\n".join(responses) if responses else clean_reply
if is_monitoring:
mem_response = ""
mem_response = re.sub(r'<[^>]+?>', '', mem_response).strip()
await add_interaction(user_id, message.channel.id, message.content, mem_response, guild_id)
# 5. Monitoring silence
if is_monitoring and not action_executed:
return
# 6. Send final reply
if not action_executed:
await self._send_final_reply(message, clean_reply, responses, json_str, accumulated_reply, is_monitoring)
async def _send_final_reply(self, message, clean_reply, responses, json_str, accumulated_reply, is_monitoring):
final_output = "\n".join(responses) if responses else clean_reply
final_output = re.sub(r'<[^>]+?>', '', final_output).strip()
if final_output:
await self.messaging.reply_with_limit(message, format_mentions_in_text(final_output))
return
if not is_monitoring:
if json_str:
try:
data = json.loads(json_str)
actions = data if isinstance(data, list) else [data]
for act in actions:
resp = act.get('response', '')
if resp:
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
return
except (json.JSONDecodeError, TypeError):
pass
await self.messaging.reply_with_limit(message, "Hmm, quelque chose s'est mal pass\u00e9. Tu peux reformuler ?")
async def _handle_beta_signals(self, action_data, message):
insight = action_data.get('insight')
alert = action_data.get('alert')
importance = action_data.get('importance', 0)
context_data = {
"author_mention": message.author.mention,
"author_name": message.author.display_name,
"channel_mention": message.channel.mention if hasattr(message.channel, 'mention') else 'DM',
"content": f"*{message.content[:250]}{'...' if len(message.content) > 250 else ''}*",
"jump_url": message.jump_url if message.jump_url else None
}
if alert and self.admin_ids:
admin_trigger_text = (
f"{alert}\n\n"
f"**CONTEXTE D\u00c9TECTION**\n"
f"- **Sujet** : {context_data['author_mention']} ({context_data['author_name']})\n"
f"- **Salon** : {context_data['channel_mention']}\n"
+ (f"- **Lien direct** : [Acc\u00e9der au message]({context_data['jump_url']})" if context_data['jump_url'] else "- **Source** : Transmission Vocale")
)
for admin_id in self.admin_ids:
admin = self.get_user(admin_id) or await self.fetch_user(admin_id)
if admin:
await self.messaging.send_embed(admin, f"ALERTE (Priorit\u00e9 {importance}/10)", admin_trigger_text, color=discord.Color.red())
if insight and self.asset_ids:
await self.dispatcher.dispatch_insight(insight, importance, message.guild, context_data)
async def _execute_actions(self, action_data, message, final_content, is_monitoring=False) -> bool:
action_executed = False
async def process_item(item):
nonlocal action_executed
if not isinstance(item, dict) or 'action' not in item:
return
action = item['action']
resp = item.get('response', "").strip()
if resp and not is_monitoring:
await self.messaging.reply_with_limit(message, format_mentions_in_text(resp))
action_executed = True
if action != 'NONE':
success, result = await self.action_executor.execute(action, item, message)
action_logger.log_action(action, str(message.author), str(message.guild), item, success, result)
if not is_monitoring:
if success:
if result:
await self.messaging.reply_with_limit(message, f"\u25c8 SUCCESS: {result}")
elif action in ('JOIN_VOICE', 'LEAVE_VOICE', 'FORGET_USER'):
confirmations = {
'JOIN_VOICE': "Je rejoins le salon vocal.",
'LEAVE_VOICE': "Je quitte le salon vocal.",
'FORGET_USER': "Vos donn\u00e9es ont \u00e9t\u00e9 supprim\u00e9es."
}
await self.messaging.reply_with_limit(message, f"\u25c8 {confirmations.get(action, 'Action ex\u00e9cut\u00e9e.')}")
else:
await self.messaging.reply_with_limit(message, f"\u25c8 ERREUR: {result or 'Une erreur est survenue.'}")
action_executed = True
if isinstance(action_data, list):
executed = set()
for item in action_data:
key = json.dumps(item, sort_keys=True)
if key in executed:
continue
executed.add(key)
await process_item(item)
elif isinstance(action_data, dict):
await process_item(action_data)
return action_executed
# ==================== Shutdown ====================
async def close(self):
try:
await flush_all()
except Exception:
logger.exception("Error flushing memories")
await self.llm.close_session()
if self.redis_worker:
await self.redis_worker.close()
await super().close()
# Backward compatibility alias
Superviseur = Bot