235 lines
10 KiB
Python
235 lines
10 KiB
Python
|
|
import json
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
import datetime
|
||
|
|
import logging
|
||
|
|
from typing import Dict, List, Optional
|
||
|
|
import discord
|
||
|
|
from .permissions import check_is_admin
|
||
|
|
|
||
|
|
logger = logging.getLogger('Superviseur')
|
||
|
|
|
||
|
|
ASSETS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets_config.json")
|
||
|
|
TASKS_DATA_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "active_tasks.json")
|
||
|
|
|
||
|
|
class AssetDispatcher:
|
||
|
|
"""Manages autonomous staff coordination and task assignments."""
|
||
|
|
|
||
|
|
def __init__(self, bot):
|
||
|
|
self.bot = bot
|
||
|
|
self.assets = self._load_data(ASSETS_DATA_PATH)
|
||
|
|
self.tasks = self._load_data(TASKS_DATA_PATH)
|
||
|
|
self.interview_states = {} # {user_id: bool}
|
||
|
|
|
||
|
|
def _load_data(self, path):
|
||
|
|
if os.path.exists(path):
|
||
|
|
with open(path, 'r') as f:
|
||
|
|
return json.load(f)
|
||
|
|
return {}
|
||
|
|
|
||
|
|
def _save_data(self, data, path):
|
||
|
|
with open(path, 'w') as f:
|
||
|
|
json.dump(data, f, indent=2)
|
||
|
|
|
||
|
|
async def start_interview(self, user_id: int):
|
||
|
|
"""Initiate the autonomous interview protocol via DM."""
|
||
|
|
if str(user_id) in self.assets:
|
||
|
|
return
|
||
|
|
|
||
|
|
user = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id)
|
||
|
|
if not user: return
|
||
|
|
|
||
|
|
self.interview_states[user_id] = True
|
||
|
|
prompt = (
|
||
|
|
"◈ PROTOCOLE D'ENTRETIEN : ACQUISITION DE DISPONIBILITÉS ◈\n\n"
|
||
|
|
"Bonjour Asset Primaire. Pour optimiser la coordination du système, j'ai besoin de connaître vos disponibilités.\n"
|
||
|
|
"Veuillez m'indiquer vos horaires habituels (cours, travail, repos) ainsi que votre fuseau horaire.\n"
|
||
|
|
"Répondez-moi naturellement, je traiterai vos données."
|
||
|
|
)
|
||
|
|
await self.bot.messaging.send_embed(user, "COMMUNICATION ENTRANTE", prompt, color=discord.Color.blue(), is_asset=True)
|
||
|
|
|
||
|
|
async def handle_dm_response(self, message: discord.Message):
|
||
|
|
"""Process natural language response from an Asset for their schedule."""
|
||
|
|
u_id = str(message.author.id)
|
||
|
|
if u_id not in self.interview_states: return False
|
||
|
|
|
||
|
|
# Indiquer que Bêta réfléchit
|
||
|
|
async with message.channel.typing():
|
||
|
|
# Use LLM to extract schedule
|
||
|
|
prompt = (
|
||
|
|
f"ANALYSE DE DISPONIBILITÉ ASSET\n"
|
||
|
|
f"Texte du membre : \"{message.content}\"\n\n"
|
||
|
|
f"FONCTION : Extraire les horaires au format JSON strict.\n"
|
||
|
|
f"FORMAT REQUIS :\n"
|
||
|
|
f"`{{\"schedule\": {{\"mon\": [[\"HH:MM\", \"HH:MM\"]], \"tue\": [], ...}}, \"timezone\": \"Europe/Paris\"}}`\n"
|
||
|
|
f"Note : Si l'Asset dit être libre tout le temps, laissez les jours vides []. S'il donne des heures, mettez-les.\n"
|
||
|
|
f"IMPORTANT: Ne répondez QUE par le bloc JSON."
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
payload = self.bot.llm.build_payload(
|
||
|
|
prompt=prompt,
|
||
|
|
system_prompt="Tu es un expert en extraction de données. Réponds uniquement en JSON.",
|
||
|
|
force_json=True
|
||
|
|
)
|
||
|
|
resp = await self.bot.llm.call_llama(payload)
|
||
|
|
json_str = self.bot.llm.extract_json_actions(resp)
|
||
|
|
|
||
|
|
if not json_str and resp.strip().startswith('{'):
|
||
|
|
json_str = resp.strip()
|
||
|
|
|
||
|
|
if json_str:
|
||
|
|
data = json.loads(json_str)
|
||
|
|
if isinstance(data, list): data = data[0]
|
||
|
|
|
||
|
|
if "schedule" in data:
|
||
|
|
self.assets[u_id] = {
|
||
|
|
"name": message.author.display_name,
|
||
|
|
"schedule": data.get("schedule", {}),
|
||
|
|
"timezone": data.get("timezone", "Europe/Paris"),
|
||
|
|
"last_update": time.time()
|
||
|
|
}
|
||
|
|
self._save_data(self.assets, ASSETS_DATA_PATH)
|
||
|
|
|
||
|
|
del self.interview_states[u_id]
|
||
|
|
await self.bot.messaging.send_embed(
|
||
|
|
message.author,
|
||
|
|
"INTÉGRATION RÉUSSIE",
|
||
|
|
"Vos paramètres de disponibilité ont été synchronisés avec succès. Je vous contacterai selon ces critères.",
|
||
|
|
color=discord.Color.green(),
|
||
|
|
is_asset=True
|
||
|
|
)
|
||
|
|
return True
|
||
|
|
|
||
|
|
# Si on arrive ici, l'extraction a échoué
|
||
|
|
logger.warning(f"Failed to extract schedule from: {resp}")
|
||
|
|
await self.bot.messaging.send_embed(
|
||
|
|
message.author,
|
||
|
|
"ÉCHEC D'ACQUISITION",
|
||
|
|
"Je n'ai pas pu parser vos horaires. Veuillez être plus précis (ex: 'Lundi de 8h à 12h') ou vérifier le format.",
|
||
|
|
color=discord.Color.orange(),
|
||
|
|
is_asset=True
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Failed to process interview response: {e}")
|
||
|
|
await message.channel.send(f"◈ ERREUR CRITIQUE DURANT L'ANALYSE : {e}")
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
def is_available(self, user_id: int, guild: discord.Guild) -> bool:
|
||
|
|
"""Check if an asset is available and physically present on the server."""
|
||
|
|
u_id = str(user_id)
|
||
|
|
|
||
|
|
# 1. Server Presence & Permission Check (only if guild is provided)
|
||
|
|
if guild:
|
||
|
|
member = guild.get_member(user_id)
|
||
|
|
if not member:
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Verify they are ACTUALLY staff on this server
|
||
|
|
if not check_is_admin(member.guild_permissions):
|
||
|
|
# logger.warning(f"Asset {member.display_name} is present but lacks STAFF permissions on {guild.name}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
# 2. Schedule Check
|
||
|
|
asset = self.assets.get(u_id)
|
||
|
|
if not asset: return True # Default available if unknown
|
||
|
|
|
||
|
|
# Simple weekday/hour check (basic implementation)
|
||
|
|
now = datetime.datetime.now() # Should use asset's timezone in real impl
|
||
|
|
day = now.strftime("%a").lower()[:3]
|
||
|
|
current_time = now.strftime("%H:%M")
|
||
|
|
|
||
|
|
schedule = asset.get("schedule", {}).get(day, [])
|
||
|
|
if not schedule: return True # Available if no specific "busy" blocks
|
||
|
|
|
||
|
|
for start, end in schedule:
|
||
|
|
if start <= current_time <= end:
|
||
|
|
return False # Occupied
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
async def dispatch_insight(self, insight: str, importance: int, guild: discord.Guild, context_data: dict):
|
||
|
|
"""Dispatch an insight to the best available asset."""
|
||
|
|
available_assets = []
|
||
|
|
for asset_id in self.bot.asset_ids:
|
||
|
|
if self.is_available(asset_id, guild):
|
||
|
|
available_assets.append(asset_id)
|
||
|
|
|
||
|
|
if not available_assets:
|
||
|
|
# Fallback to Admins if no asset available
|
||
|
|
for admin_id in self.bot.admin_ids:
|
||
|
|
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
|
||
|
|
if admin:
|
||
|
|
await self.bot.messaging.send_embed(admin, "DISPATCH FALLBACK: NO ASSETS AVAILABLE", insight, color=discord.Color.gold())
|
||
|
|
return
|
||
|
|
|
||
|
|
# Target selection (Simple round-robin or first available for now)
|
||
|
|
target_id = available_assets[0]
|
||
|
|
target_user = self.bot.get_user(target_id) or await self.bot.fetch_user(target_id)
|
||
|
|
if target_user:
|
||
|
|
# Create Task ID and Data
|
||
|
|
task_id = f"task_{int(time.time())}"
|
||
|
|
self.tasks[task_id] = {
|
||
|
|
"asset_id": target_id,
|
||
|
|
"content": insight,
|
||
|
|
"importance": importance,
|
||
|
|
"created_at": time.time(),
|
||
|
|
"deadline": time.time() + (60 * (11 - importance) * 5),
|
||
|
|
"guild_id": guild.id if guild else None,
|
||
|
|
"status": "PENDING"
|
||
|
|
}
|
||
|
|
self._save_data(self.tasks, TASKS_DATA_PATH)
|
||
|
|
|
||
|
|
view = TaskAcceptView(task_id, self)
|
||
|
|
|
||
|
|
# Création des champs d'information structurés
|
||
|
|
fields = [
|
||
|
|
{"name": "👤 Sujet", "value": f"{context_data['author_mention']} ({context_data['author_name']})", "inline": True},
|
||
|
|
{"name": "📍 Salon", "value": context_data['channel_mention'], "inline": True},
|
||
|
|
{"name": "📝 Contenu", "value": context_data['content'], "inline": False}
|
||
|
|
]
|
||
|
|
|
||
|
|
# Ajout du lien direct s'il existe
|
||
|
|
if context_data.get('jump_url'):
|
||
|
|
fields.append({"name": "🔗 Lien Direct", "value": f"[Accéder au message]({context_data['jump_url']})", "inline": False})
|
||
|
|
|
||
|
|
desc = (
|
||
|
|
f"**MISSION OPÉRATIONNELLE**\n"
|
||
|
|
f"{insight}\n\n"
|
||
|
|
f"*Veuillez confirmer la prise en charge pour verrouiller la tâche.*"
|
||
|
|
)
|
||
|
|
|
||
|
|
await self.bot.messaging.send_embed(
|
||
|
|
target_user,
|
||
|
|
f"ASSIGNATION TACTIQUE (Priorité {importance}/10)",
|
||
|
|
desc,
|
||
|
|
color=discord.Color.blue(),
|
||
|
|
is_asset=True,
|
||
|
|
fields=fields
|
||
|
|
)
|
||
|
|
# Send view separately
|
||
|
|
await target_user.send(view=view)
|
||
|
|
|
||
|
|
class TaskAcceptView(discord.ui.View):
|
||
|
|
def __init__(self, task_id, dispatcher):
|
||
|
|
super().__init__(timeout=None)
|
||
|
|
self.task_id = task_id
|
||
|
|
self.dispatcher = dispatcher
|
||
|
|
|
||
|
|
@discord.ui.button(label="Accepter la tâche", style=discord.ButtonStyle.green)
|
||
|
|
async def accept(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
|
|
task = self.dispatcher.tasks.get(self.task_id)
|
||
|
|
if task:
|
||
|
|
task["status"] = "ACCEPTED"
|
||
|
|
task["accepted_at"] = time.time()
|
||
|
|
self.dispatcher._save_data(self.dispatcher.tasks, TASKS_DATA_PATH)
|
||
|
|
|
||
|
|
button.disabled = True
|
||
|
|
button.label = "Acceptée ✅"
|
||
|
|
await interaction.response.edit_message(view=self)
|
||
|
|
await interaction.followup.send("◈ Tâche verrouillée. Bonne intervention.", ephemeral=True)
|
||
|
|
else:
|
||
|
|
await interaction.response.send_message("Tâche introuvable ou expirée.", ephemeral=True)
|