Merge branch 'dev' into 'main'
Intégration récupération automatique des infos des agent O.K See merge request Omega_Kube/kuby!3
This commit is contained in:
commit
9974a2b6b3
13 changed files with 1936 additions and 18 deletions
5
bot.py
5
bot.py
|
|
@ -65,7 +65,10 @@ class MyBot(commands.Bot):
|
|||
"commandes.modules_security.logs_manager",
|
||||
"commandes.modules_security.rules",
|
||||
"commandes.ticket_whitelist",
|
||||
"commandes.bug_report"
|
||||
"commandes.bug_report",
|
||||
"commandes.convocation",
|
||||
"commandes.absence_staff",
|
||||
"commandes.website_sync",
|
||||
]
|
||||
|
||||
for extension in extensions:
|
||||
|
|
|
|||
512
commandes/absence_staff.py
Executable file
512
commandes/absence_staff.py
Executable file
|
|
@ -0,0 +1,512 @@
|
|||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import app_commands, ui
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError:
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
PARIS_TZ = ZoneInfo("Europe/Paris")
|
||||
DATE_FORMAT = "%d/%m/%Y %H:%M"
|
||||
|
||||
ABSENCE_SETTINGS_FILE = "data/absence_settings.json"
|
||||
ABSENCE_DATA_FILE = "data/absences.json"
|
||||
file_lock = asyncio.Lock()
|
||||
|
||||
def load_settings(guild_id: int) -> dict:
|
||||
"""Charge les paramètres d'absence pour un serveur"""
|
||||
if not os.path.exists(ABSENCE_SETTINGS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f:
|
||||
return json.load(f).get(str(guild_id), {})
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
def save_settings(guild_id: int, settings: dict) -> None:
|
||||
"""Sauvegarde les paramètres d'absence"""
|
||||
data = {}
|
||||
if os.path.exists(ABSENCE_SETTINGS_FILE):
|
||||
try:
|
||||
with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
data[str(guild_id)] = settings
|
||||
os.makedirs(os.path.dirname(ABSENCE_SETTINGS_FILE), exist_ok=True)
|
||||
with open(ABSENCE_SETTINGS_FILE, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
async def load_absences() -> dict:
|
||||
"""Charge toutes les absences (tous serveurs confondus)"""
|
||||
async with file_lock:
|
||||
if not os.path.exists(ABSENCE_DATA_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(ABSENCE_DATA_FILE, "r", encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
async def save_absences(data: dict) -> None:
|
||||
"""Sauvegarde toutes les absences"""
|
||||
async with file_lock:
|
||||
os.makedirs(os.path.dirname(ABSENCE_DATA_FILE), exist_ok=True)
|
||||
temp_file = ABSENCE_DATA_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, ABSENCE_DATA_FILE)
|
||||
|
||||
def get_guild_absences(all_absences: dict, guild_id: int) -> dict:
|
||||
"""Filtre les absences pour un serveur spécifique"""
|
||||
return all_absences.get(str(guild_id), {})
|
||||
|
||||
def update_guild_absences(all_absences: dict, guild_id: int, guild_data: dict) -> dict:
|
||||
"""Met à jour les absences pour un serveur spécifique"""
|
||||
all_absences[str(guild_id)] = guild_data
|
||||
return all_absences
|
||||
|
||||
def get_paris_now():
|
||||
"""Récupère l'heure actuelle à Paris"""
|
||||
return datetime.now(PARIS_TZ)
|
||||
|
||||
def parse_date(date_str: str):
|
||||
"""Parse une date au format JJ/MM/AAAA HH:MM en timezone Paris"""
|
||||
return datetime.strptime(date_str, DATE_FORMAT).replace(tzinfo=PARIS_TZ)
|
||||
|
||||
# --- MODALS & VIEWS ---
|
||||
|
||||
class AbsenceModal(ui.Modal, title="Déclarer une Absence Staff"):
|
||||
motif = ui.TextInput(
|
||||
label="Motif de l'absence",
|
||||
style=discord.TextStyle.paragraph,
|
||||
placeholder="Ex: Vacances, Raisons personnelles, Travail...",
|
||||
required=True,
|
||||
max_length=500
|
||||
)
|
||||
date_debut = ui.TextInput(
|
||||
label="Début (JJ/MM/AAAA HH:MM)",
|
||||
placeholder="Ex: 15/05/2024 08:00",
|
||||
min_length=16,
|
||||
max_length=16,
|
||||
required=True
|
||||
)
|
||||
date_fin = ui.TextInput(
|
||||
label="Fin (JJ/MM/AAAA HH:MM)",
|
||||
placeholder="Ex: 20/05/2024 18:00",
|
||||
min_length=16,
|
||||
max_length=16,
|
||||
required=True
|
||||
)
|
||||
|
||||
def __init__(self, superieur: Optional[discord.Member] = None):
|
||||
super().__init__()
|
||||
self.superieur = superieur
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
try:
|
||||
debut = parse_date(self.date_debut.value)
|
||||
fin = parse_date(self.date_fin.value)
|
||||
except ValueError:
|
||||
return await interaction.response.send_message(f"❌ Format de date invalide. Utilise le format: `{DATE_FORMAT.replace('%d', 'JJ').replace('%m', 'MM').replace('%Y', 'AAAA').replace('%H', 'HH').replace('%M', 'MM')}`", ephemeral=True)
|
||||
|
||||
if debut > fin:
|
||||
return await interaction.response.send_message("❌ La date de début doit précéder la date de fin.", ephemeral=True)
|
||||
|
||||
# Vérification si déjà absent
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
|
||||
if str(interaction.user.id) in guild_abs:
|
||||
return await interaction.response.send_message("❌ Tu as déjà déclaré une absence sur ce serveur.", ephemeral=True)
|
||||
|
||||
settings = load_settings(interaction.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
if not channel_id:
|
||||
return await interaction.response.send_message("❌ Le salon d'absence n'est pas configuré sur ce serveur. Contacte un administrateur.", ephemeral=True)
|
||||
|
||||
channel = interaction.guild.get_channel(int(channel_id))
|
||||
if not channel:
|
||||
return await interaction.response.send_message("❌ Salon d'absence introuvable. Vérifie la configuration.", ephemeral=True)
|
||||
|
||||
duree_delta = fin - debut
|
||||
duree_jours = duree_delta.days
|
||||
duree_heures = duree_delta.seconds // 3600
|
||||
|
||||
duree_str = f"{duree_jours}j {duree_heures}h" if duree_jours > 0 else f"{duree_heures}h"
|
||||
|
||||
now = get_paris_now()
|
||||
etat = "à venir" if debut > now else "en cours"
|
||||
|
||||
# Message dans le salon
|
||||
absence_message = await channel.send(
|
||||
f"# ABSENCE STAFF :\n"
|
||||
f"- **Pseudo Discord** : {interaction.user.mention}\n"
|
||||
f"- **Durée prévue** : {duree_str}\n"
|
||||
f"- **Débute le** : {self.date_debut.value} (Paris)\n"
|
||||
f"- **Termine le** : {self.date_fin.value} (Paris)\n"
|
||||
f"- **Motif** : {self.motif.value}\n"
|
||||
f"- **Mention du supérieur hiérarchique** : {self.superieur.mention if self.superieur else 'Aucun'}"
|
||||
)
|
||||
|
||||
# Enregistrement
|
||||
guild_abs[str(interaction.user.id)] = {
|
||||
"pseudo": interaction.user.name,
|
||||
"debut": self.date_debut.value,
|
||||
"fin": self.date_fin.value,
|
||||
"motif": self.motif.value,
|
||||
"superieur": self.superieur.mention if self.superieur else "Aucun",
|
||||
"message_id": absence_message.id,
|
||||
"etat": etat
|
||||
}
|
||||
|
||||
all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs)
|
||||
await save_absences(all_abs)
|
||||
|
||||
# Attribution du rôle si déjà en cours
|
||||
if etat == "en cours":
|
||||
role_id = settings.get("absence_role_id")
|
||||
if role_id:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role:
|
||||
try:
|
||||
await interaction.user.add_roles(role, reason="Début d'absence staff")
|
||||
except: pass
|
||||
|
||||
await interaction.response.send_message("✅ Absence enregistrée avec succès !", ephemeral=True)
|
||||
|
||||
class AbsenceConfigView(ui.View):
|
||||
def __init__(self, guild_id: int):
|
||||
super().__init__(timeout=60)
|
||||
self.guild_id = guild_id
|
||||
|
||||
@ui.button(label="Définir le Salon", style=discord.ButtonStyle.primary, emoji="📁")
|
||||
async def set_channel(self, interaction: discord.Interaction, button: ui.Button):
|
||||
await interaction.response.send_message("Mentionne le salon ici (ou envoie son ID) :", ephemeral=True)
|
||||
|
||||
def check(m):
|
||||
return m.author == interaction.user and m.channel == interaction.channel
|
||||
|
||||
try:
|
||||
msg = await interaction.client.wait_for('message', check=check, timeout=30)
|
||||
channel = None
|
||||
if msg.channel_mentions:
|
||||
channel = msg.channel_mentions[0]
|
||||
elif msg.content.isdigit():
|
||||
channel = interaction.guild.get_channel(int(msg.content))
|
||||
|
||||
if not channel:
|
||||
return await interaction.followup.send("❌ Salon invalide.", ephemeral=True)
|
||||
|
||||
settings = load_settings(self.guild_id)
|
||||
settings["absence_channel_id"] = channel.id
|
||||
save_settings(self.guild_id, settings)
|
||||
await interaction.followup.send(f"✅ Salon d'absence configuré sur {channel.mention}", ephemeral=True)
|
||||
try: await msg.delete()
|
||||
except: pass
|
||||
except asyncio.TimeoutError:
|
||||
await interaction.followup.send("❌ Temps écoulé.", ephemeral=True)
|
||||
|
||||
@ui.button(label="Définir le Rôle", style=discord.ButtonStyle.primary, emoji="🎭")
|
||||
async def set_role(self, interaction: discord.Interaction, button: ui.Button):
|
||||
await interaction.response.send_message("Mentionne le rôle ici (ou envoie son ID) :", ephemeral=True)
|
||||
|
||||
def check(m):
|
||||
return m.author == interaction.user and m.channel == interaction.channel
|
||||
|
||||
try:
|
||||
msg = await interaction.client.wait_for('message', check=check, timeout=30)
|
||||
role = None
|
||||
if msg.role_mentions:
|
||||
role = msg.role_mentions[0]
|
||||
elif msg.content.isdigit():
|
||||
role = interaction.guild.get_role(int(msg.content))
|
||||
|
||||
if not role:
|
||||
return await interaction.followup.send("❌ Rôle invalide.", ephemeral=True)
|
||||
|
||||
settings = load_settings(self.guild_id)
|
||||
settings["absence_role_id"] = role.id
|
||||
save_settings(self.guild_id, settings)
|
||||
await interaction.followup.send(f"✅ Rôle d'absence configuré sur **{role.name}**", ephemeral=True)
|
||||
try: await msg.delete()
|
||||
except: pass
|
||||
except asyncio.TimeoutError:
|
||||
await interaction.followup.send("❌ Temps écoulé.", ephemeral=True)
|
||||
|
||||
class Absence(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.check_absences.start()
|
||||
|
||||
@app_commands.command(name="absence", description="Déclarer une absence staff")
|
||||
@app_commands.describe(superieur="Le supérieur hiérarchique à prévenir")
|
||||
async def absence(self, interaction: discord.Interaction, superieur: Optional[discord.Member] = None):
|
||||
"""Déclare une absence via un formulaire"""
|
||||
await interaction.response.send_modal(AbsenceModal(superieur))
|
||||
|
||||
@app_commands.command(name="absence_config", description="Configurer le système d'absence (Admin)")
|
||||
@app_commands.checks.has_permissions(administrator=True)
|
||||
async def absence_config(self, interaction: discord.Interaction):
|
||||
"""Ouvre le panneau de configuration des absences"""
|
||||
settings = load_settings(interaction.guild.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="⚙️ Configuration des Absences Staff",
|
||||
description="Utilisez les boutons ci-dessous pour configurer le système.",
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
role_id = settings.get("absence_role_id")
|
||||
|
||||
embed.add_field(
|
||||
name="📁 Salon d'affichage",
|
||||
value=f"<#{channel_id}>" if channel_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="🎭 Rôle d'absence",
|
||||
value=f"<@&{role_id}>" if role_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, view=AbsenceConfigView(interaction.guild.id), ephemeral=True)
|
||||
|
||||
async def strike_absence_message(self, guild: discord.Guild, channel_id: int, message_id: int):
|
||||
"""Modifie le message d'absence pour le barrer au lieu de le supprimer"""
|
||||
channel = guild.get_channel(channel_id)
|
||||
if not channel:
|
||||
try:
|
||||
channel = await guild.fetch_channel(channel_id)
|
||||
except:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(message_id)
|
||||
content = msg.content
|
||||
|
||||
if "~~" in content: # Déjà barré
|
||||
return
|
||||
|
||||
lines = content.split("\n")
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
# Conserver le style header mais barrer le texte
|
||||
new_lines.append(f"# ~~{line[2:].strip()}~~ [TERMINÉ]")
|
||||
elif line.strip():
|
||||
new_lines.append(f"~~{line}~~")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
await msg.edit(content="\n".join(new_lines))
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du barrage du message d'absence: {e}")
|
||||
|
||||
@app_commands.command(name="fin_absence", description="Terminer son absence staff")
|
||||
async def fin_absence(self, interaction: discord.Interaction):
|
||||
"""Met fin à l'absence en cours"""
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
user_id = str(interaction.user.id)
|
||||
|
||||
if user_id not in guild_abs:
|
||||
return await interaction.response.send_message("❌ Tu n'as pas d'absence enregistrée sur ce serveur.", ephemeral=True)
|
||||
|
||||
info = guild_abs[user_id]
|
||||
message_id = info.get("message_id")
|
||||
|
||||
settings = load_settings(interaction.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
if channel_id and message_id:
|
||||
await self.strike_absence_message(interaction.guild, int(channel_id), int(message_id))
|
||||
|
||||
# Retrait du rôle
|
||||
role_id = settings.get("absence_role_id")
|
||||
if role_id:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role:
|
||||
try: await interaction.user.remove_roles(role, reason="Fin d'absence staff")
|
||||
except: pass
|
||||
|
||||
del guild_abs[user_id]
|
||||
all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs)
|
||||
await save_absences(all_abs)
|
||||
await interaction.response.send_message("✅ Ton absence a été retirée.", ephemeral=True)
|
||||
|
||||
@app_commands.command(name="liste_absences", description="Afficher la liste des absences staff en cours")
|
||||
async def liste_absences(self, interaction: discord.Interaction):
|
||||
"""Affiche les absences en cours sur le serveur"""
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
|
||||
absences = [
|
||||
(user_id, info) for user_id, info in guild_abs.items()
|
||||
if info.get("etat") == "en cours"
|
||||
]
|
||||
|
||||
if not absences:
|
||||
return await interaction.response.send_message("Aucune absence staff en cours sur ce serveur.", ephemeral=True)
|
||||
|
||||
per_page = 5
|
||||
pages = [absences[i:i + per_page] for i in range(0, len(absences), per_page)]
|
||||
|
||||
def create_embed(page_index: int) -> discord.Embed:
|
||||
embed = discord.Embed(
|
||||
title=f"📋 Absences staff - {interaction.guild.name} ({page_index + 1}/{len(pages)})",
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
for user_id, info in pages[page_index]:
|
||||
embed.add_field(
|
||||
name=info["pseudo"],
|
||||
value=(
|
||||
f"**Période** : du {info['debut']} au {info['fin']}\n"
|
||||
f"**Motif** : {info['motif']}\n"
|
||||
f"**Supérieur** : {info['superieur']}"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(text="Gestion des absences du staff")
|
||||
return embed
|
||||
|
||||
class PaginatorView(ui.View):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=60)
|
||||
self.page = 0
|
||||
|
||||
@ui.button(label="⬅️ Précédent", style=discord.ButtonStyle.secondary, disabled=True)
|
||||
async def previous(self, interaction_btn: discord.Interaction, button: discord.ui.Button):
|
||||
self.page -= 1
|
||||
self.update_buttons()
|
||||
await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self)
|
||||
|
||||
@ui.button(label="➡️ Suivant", style=discord.ButtonStyle.secondary, disabled=len(pages) == 1)
|
||||
async def next(self, interaction_btn: discord.Interaction, button: discord.ui.Button):
|
||||
self.page += 1
|
||||
self.update_buttons()
|
||||
await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self)
|
||||
|
||||
def update_buttons(self):
|
||||
self.children[0].disabled = self.page == 0
|
||||
self.children[1].disabled = self.page >= len(pages) - 1
|
||||
|
||||
await interaction.response.send_message(embed=create_embed(0), view=PaginatorView(), ephemeral=True)
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_absences(self):
|
||||
all_abs = await load_absences()
|
||||
now = get_paris_now()
|
||||
changed = False
|
||||
|
||||
for guild_id_str, guild_data in list(all_abs.items()):
|
||||
guild_id = int(guild_id_str)
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
try:
|
||||
guild = await self.bot.fetch_guild(guild_id)
|
||||
except:
|
||||
continue
|
||||
|
||||
settings = load_settings(guild_id)
|
||||
role_id = settings.get("absence_role_id")
|
||||
role = guild.get_role(int(role_id)) if role_id else None
|
||||
|
||||
to_remove = []
|
||||
for user_id_str, info in guild_data.items():
|
||||
try:
|
||||
debut = parse_date(info["debut"])
|
||||
fin = parse_date(info["fin"])
|
||||
except Exception as e:
|
||||
print(f"Erreur parsing date pour {user_id_str}: {e}")
|
||||
continue
|
||||
|
||||
user_id = int(user_id_str)
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
try:
|
||||
member = await guild.fetch_member(user_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
etat = info.get("etat", "en cours")
|
||||
|
||||
# Activation si l'heure est arrivée
|
||||
if etat == "à venir" and debut <= now:
|
||||
print(f"Activation absence pour {info['pseudo']} ({user_id_str})")
|
||||
info["etat"] = "en cours"
|
||||
changed = True
|
||||
if member and role:
|
||||
try:
|
||||
await member.add_roles(role, reason="Début d'absence staff")
|
||||
except Exception as e:
|
||||
print(f"Erreur ajout rôle absence: {e}")
|
||||
|
||||
# Désactivation si l'heure est passée
|
||||
if fin < now:
|
||||
print(f"Désactivation absence pour {info['pseudo']} ({user_id_str})")
|
||||
if member and role:
|
||||
try:
|
||||
await member.remove_roles(role, reason="Fin d'absence staff")
|
||||
except Exception as e:
|
||||
print(f"Erreur retrait rôle absence: {e}")
|
||||
|
||||
# On barre aussi le message en automatique
|
||||
message_id = info.get("message_id")
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
if channel_id and message_id:
|
||||
await self.strike_absence_message(guild, int(channel_id), int(message_id))
|
||||
|
||||
to_remove.append(user_id_str)
|
||||
changed = True
|
||||
|
||||
for uid in to_remove:
|
||||
guild_data.pop(uid, None)
|
||||
|
||||
if changed:
|
||||
await save_absences(all_abs)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message):
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, message.guild.id)
|
||||
|
||||
# 1. Rappel à l'auteur s'il est absent
|
||||
if str(message.author.id) in guild_abs:
|
||||
if guild_abs[str(message.author.id)].get("etat") == "en cours":
|
||||
await message.channel.send(
|
||||
f"{message.author.mention}, tu es actuellement déclaré absent sur ce serveur. Merci de prévenir si tu es de retour !",
|
||||
delete_after=10
|
||||
)
|
||||
|
||||
# 2. Notification des mentions de staff absents
|
||||
for user_id_str, info in guild_abs.items():
|
||||
if info.get("etat") != "en cours":
|
||||
continue
|
||||
|
||||
if f"<@{user_id_str}>" in message.content or f"<@!{user_id_str}>" in message.content:
|
||||
msg_id = info.get("message_id")
|
||||
settings = load_settings(message.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
absence_url = f"https://discord.com/channels/{message.guild.id}/{channel_id}/{msg_id}" if channel_id and msg_id else ""
|
||||
|
||||
reply_text = f"⚠️ {message.author.mention}, cet utilisateur est actuellement absent."
|
||||
if absence_url:
|
||||
reply_text += f"\nPlus d'infos ici : [Voir la déclaration]({absence_url})"
|
||||
|
||||
await message.reply(reply_text, delete_after=30)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Absence(bot))
|
||||
285
commandes/convocation.py
Executable file
285
commandes/convocation.py
Executable file
|
|
@ -0,0 +1,285 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
import datetime
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
CONV_SETTINGS_FILE = "data/convocation_settings.json"
|
||||
CONV_BACKUPS_FILE = "data/convocation_backups.json"
|
||||
|
||||
def load_json(filepath: str) -> Dict:
|
||||
if not os.path.exists(filepath):
|
||||
return {}
|
||||
try:
|
||||
with open(filepath, "r", encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
def save_json(filepath: str, data: Dict):
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
class Convocation(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.settings = load_json(CONV_SETTINGS_FILE)
|
||||
self.backups = load_json(CONV_BACKUPS_FILE)
|
||||
|
||||
def get_guild_settings(self, guild_id: int) -> Dict:
|
||||
return self.settings.get(str(guild_id), {})
|
||||
|
||||
def save_guild_settings(self, guild_id: int, guild_settings: Dict):
|
||||
self.settings[str(guild_id)] = guild_settings
|
||||
save_json(CONV_SETTINGS_FILE, self.settings)
|
||||
|
||||
def get_backup(self, guild_id: int, user_id: int) -> Optional[Dict]:
|
||||
key = f"{guild_id}_{user_id}"
|
||||
return self.backups.get(key)
|
||||
|
||||
def save_backup(self, guild_id: int, user_id: int, backup_data: Dict):
|
||||
key = f"{guild_id}_{user_id}"
|
||||
self.backups[key] = backup_data
|
||||
save_json(CONV_BACKUPS_FILE, self.backups)
|
||||
|
||||
def clear_backup(self, guild_id: int, user_id: int):
|
||||
key = f"{guild_id}_{user_id}"
|
||||
if key in self.backups:
|
||||
del self.backups[key]
|
||||
save_json(CONV_BACKUPS_FILE, self.backups)
|
||||
|
||||
@commands.hybrid_group(name="convocation", description="Gère les convocations de membres")
|
||||
@commands.has_permissions(manage_roles=True)
|
||||
async def convocation_group(self, ctx):
|
||||
if ctx.invoked_subcommand is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
|
||||
@convocation_group.command(name="convoquer", description="Convoque un membre et verrouille ses accès")
|
||||
@app_commands.describe(
|
||||
membre="Le membre à convoquer",
|
||||
raison="La raison de la convocation",
|
||||
heure="L'heure de la convocation (ex: 14:30)"
|
||||
)
|
||||
async def convoquer(self, ctx, membre: discord.Member, raison: str, heure: str):
|
||||
guild_id = ctx.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
role_conv_id = settings.get("role_id")
|
||||
log_channel_id = settings.get("log_channel_id")
|
||||
attente_id = settings.get("attente_id") # Salon d'attente à ignorer
|
||||
admin_ids = settings.get("admin_ids", [])
|
||||
|
||||
if not role_conv_id:
|
||||
await ctx.send("❌ Le rôle de convocation n'est pas configuré. Utilisez `/convocation config`.", ephemeral=True)
|
||||
return
|
||||
|
||||
await ctx.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
# --- BACKUP ---
|
||||
bot_top_role = ctx.guild.me.top_role
|
||||
backup_data = {
|
||||
"roles": [r.id for r in membre.roles if not r.is_default() and r.id != role_conv_id and r.position < bot_top_role.position],
|
||||
"overrides": {}
|
||||
}
|
||||
|
||||
# 1. Backup and Remove current roles
|
||||
current_roles = [ctx.guild.get_role(rid) for rid in backup_data["roles"] if ctx.guild.get_role(rid)]
|
||||
if current_roles:
|
||||
try:
|
||||
await membre.remove_roles(*current_roles, reason=f"Convocation par {ctx.author}")
|
||||
except discord.Forbidden:
|
||||
await ctx.send("⚠️ Je n'ai pas pu retirer certains rôles (Permission refusée).", ephemeral=True)
|
||||
|
||||
# 2. Dynamic Lockdown: Loop through ALL channels
|
||||
lockdown_count = 0
|
||||
for channel in ctx.guild.channels:
|
||||
# On ignore le salon d'attente et ses parents s'il y en a
|
||||
if attente_id and (channel.id == attente_id or (hasattr(channel, 'category') and channel.category and channel.category.id == attente_id)):
|
||||
continue
|
||||
|
||||
# Check existing override for the member
|
||||
overwrites = channel.overwrites_for(membre)
|
||||
if not overwrites.is_empty():
|
||||
# Save existing override (as dict of pair of permission name and boolean/None)
|
||||
backup_data["overrides"][str(channel.id)] = {k: v for k, v in dict(overwrites).items() if v is not None}
|
||||
|
||||
# Apply Lockdown override for the member
|
||||
try:
|
||||
# Specific override for this member: View Channel = False
|
||||
await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown")
|
||||
lockdown_count += 1
|
||||
except discord.Forbidden:
|
||||
pass # Skip if we can't touch this channel
|
||||
|
||||
self.save_backup(guild_id, membre.id, backup_data)
|
||||
|
||||
# 3. Add Convocation role
|
||||
role_conv = ctx.guild.get_role(role_conv_id)
|
||||
if role_conv:
|
||||
if role_conv.position >= bot_top_role.position:
|
||||
await ctx.send(f"⚠️ Je ne peux pas donner le rôle {role_conv.mention} car il est plus haut que moi !", ephemeral=True)
|
||||
else:
|
||||
try:
|
||||
await membre.add_roles(role_conv, reason="Début de convocation")
|
||||
except discord.Forbidden:
|
||||
await ctx.send(f"⚠️ Impossible de donner le rôle {role_conv.name} (Permission refusée).", ephemeral=True)
|
||||
|
||||
# 4. Embeds & Logs (Inchangés mais inclus pour la cohérence)
|
||||
embed_conv = discord.Embed(
|
||||
title="CONVOCATION | 🔖",
|
||||
description=f"> Bonjour {membre.mention}, vous êtes **convoqué** par {ctx.author.mention}. Voici les **détails** :",
|
||||
color=0x0099ff,
|
||||
timestamp=discord.utils.utcnow()
|
||||
)
|
||||
embed_conv.add_field(name="**Raison de la convocation :**", value=f"➜ {raison}", inline=False)
|
||||
embed_conv.add_field(name="**Heure de la convocation :**", value=f"➜ {heure}", inline=False)
|
||||
embed_conv.add_field(name="\u200B", value=(
|
||||
"> Merci de vous présenter dans le vocal attente **5 minutes** avant l'heure.\n\n"
|
||||
"> La **levée** de suspension se fera uniquement à la **fin** de la convocation."
|
||||
), inline=False)
|
||||
embed_conv.set_footer(text=f"Gestion de {ctx.guild.name}")
|
||||
|
||||
embed_log = discord.Embed(
|
||||
title="📋 CONVOCATION ENVOYÉE",
|
||||
color=0xff9900,
|
||||
timestamp=discord.utils.utcnow()
|
||||
)
|
||||
embed_log.add_field(name="👤 Membre convoqué", value=f"{membre.mention} ({membre.id})", inline=True)
|
||||
embed_log.add_field(name="👮 Convoqué par", value=f"{ctx.author.mention}", inline=True)
|
||||
embed_log.add_field(name="📄 Raison", value=raison, inline=False)
|
||||
embed_log.add_field(name="🕒 Heure", value=heure, inline=False)
|
||||
|
||||
# DM
|
||||
dm_sent = False
|
||||
try:
|
||||
await membre.send(embed=embed_conv)
|
||||
dm_sent = True
|
||||
except discord.Forbidden:
|
||||
dm_sent = False
|
||||
|
||||
# Admin & Channel Logs
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
admin = await self.bot.fetch_user(admin_id)
|
||||
await admin.send(embed=embed_log)
|
||||
except: pass
|
||||
|
||||
if log_channel_id:
|
||||
chan = ctx.guild.get_channel(log_channel_id)
|
||||
if chan: await chan.send(embed=embed_log)
|
||||
|
||||
await ctx.send(f"✅ Convocation envoyée à {membre.mention}. ({lockdown_count} salons verrouillés) " +
|
||||
("" if dm_sent else "(⚠️ DM impossible)"), ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await ctx.send(f"❌ Une erreur critique est survenue : {e}", ephemeral=True)
|
||||
|
||||
@convocation_group.command(name="lever", description="Lève la convocation d'un membre et restaure ses accès")
|
||||
@app_commands.describe(membre="Le membre à libérer")
|
||||
async def lever(self, ctx, membre: discord.Member):
|
||||
guild_id = ctx.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
role_conv_id = settings.get("role_id")
|
||||
|
||||
await ctx.defer(ephemeral=True)
|
||||
|
||||
backup_data = self.get_backup(guild_id, membre.id)
|
||||
if not backup_data:
|
||||
await ctx.send("⚠️ Aucune donnée de convocation trouvée pour ce membre.", ephemeral=True)
|
||||
return
|
||||
|
||||
# 1. Remove Convocation role
|
||||
if role_conv_id:
|
||||
role_conv = ctx.guild.get_role(role_conv_id)
|
||||
if role_conv and role_conv in membre.roles:
|
||||
await membre.remove_roles(role_conv, reason="Fin de convocation")
|
||||
|
||||
# 2. Restore channel overrides
|
||||
restore_count = 0
|
||||
overrides = backup_data.get("overrides", {})
|
||||
for channel in ctx.guild.channels:
|
||||
chan_id_str = str(channel.id)
|
||||
try:
|
||||
if chan_id_str in overrides:
|
||||
# Restore previous override
|
||||
ov_data = overrides[chan_id_str]
|
||||
overwrite = discord.PermissionOverwrite(**ov_data)
|
||||
await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation")
|
||||
restore_count += 1
|
||||
else:
|
||||
# Clear our lockdown override
|
||||
# On ne vide que si un override membre existait (sinon on risque de supprimer des perms rôles ?)
|
||||
# En fait set_permissions(member, overwrite=None) supprime l'entrée PERSONNELLE du membre.
|
||||
await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup")
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
# 3. Restore roles
|
||||
role_ids = backup_data.get("roles", [])
|
||||
restored_roles = []
|
||||
if role_ids:
|
||||
for rid in role_ids:
|
||||
role = ctx.guild.get_role(rid)
|
||||
if role and role.position < ctx.guild.me.top_role.position:
|
||||
restored_roles.append(role)
|
||||
|
||||
if restored_roles:
|
||||
await membre.add_roles(*restored_roles, reason="Restauration fin de convocation")
|
||||
|
||||
self.clear_backup(guild_id, membre.id)
|
||||
|
||||
# 4. Notification
|
||||
embed = discord.Embed(
|
||||
title="🔓 CONVOCATION TERMINÉE",
|
||||
description=f"Votre convocation sur **{ctx.guild.name}** est terminée. Vos accès ont été restaurés.",
|
||||
color=discord.Color.green()
|
||||
)
|
||||
try:
|
||||
await membre.send(embed=embed)
|
||||
except: pass
|
||||
|
||||
await ctx.send(f"✅ Convocation levée pour {membre.mention}. Access restaurés.", ephemeral=True)
|
||||
|
||||
@convocation_group.command(name="config", description="Configure le système de convocation")
|
||||
@app_commands.describe(
|
||||
role="Le rôle de convocation (Muet/Convocation)",
|
||||
channel="Le salon de logs",
|
||||
attente="Salon (ou catégorie) d'attente à laisser visible",
|
||||
admins="Liste d'IDs d'admins à notifier (séparés par des espaces)"
|
||||
)
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def config(self, ctx, role: discord.Role, channel: discord.TextChannel = None, attente: discord.abc.GuildChannel = None, admins: str = None):
|
||||
guild_id = ctx.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
|
||||
settings["role_id"] = role.id
|
||||
if channel:
|
||||
settings["log_channel_id"] = channel.id
|
||||
if attente:
|
||||
settings["attente_id"] = attente.id
|
||||
|
||||
if admins:
|
||||
admin_list = []
|
||||
for uid in admins.split():
|
||||
if uid.isdigit():
|
||||
admin_list.append(int(uid))
|
||||
settings["admin_ids"] = admin_list
|
||||
|
||||
self.save_guild_settings(guild_id, settings)
|
||||
|
||||
embed = discord.Embed(title="⚙️ Configuration Convocation Mise à jour", color=discord.Color.blue())
|
||||
embed.add_field(name="Rôle", value=role.mention)
|
||||
embed.add_field(name="Salon Logs", value=channel.mention if channel else "Non défini")
|
||||
embed.add_field(name="Salon/Cat d'attente", value=attente.mention if attente else "Non défini")
|
||||
embed.add_field(name="Admins notifiés", value=str(len(settings.get("admin_ids", []))))
|
||||
|
||||
await ctx.send(embed=embed, ephemeral=True)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Convocation(bot))
|
||||
473
commandes/website_sync.py
Normal file
473
commandes/website_sync.py
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"""
|
||||
Cog : website_sync
|
||||
Commandes pour gérer la synchronisation JSON du site web.
|
||||
|
||||
Slash commands disponibles :
|
||||
/json-generate [cible] — Génère les JSON manuellement
|
||||
/json-status — État des fichiers + prochaine génération
|
||||
/json-preview [cible] [limite] — Aperçu sans écrire les fichiers
|
||||
/json-history [limite] — Historique des N dernières générations
|
||||
/json-config show — Affiche la config actuelle
|
||||
/json-config set-guild <id> — Définit le serveur cible (members.json)
|
||||
/json-config set-schedule <h> [m] — Heure de génération auto
|
||||
/json-config set-output-dir <chemin> — Changer le dossier de sortie
|
||||
/json-config add-founder-role <nom> — Ajouter un nom de rôle fondateur
|
||||
/json-config remove-founder-role <nom> — Retirer un nom de rôle fondateur
|
||||
/json-config server-field <champ> <actif> — Active/désactive un champ servers.json
|
||||
/json-config member-field <champ> <actif> — Active/désactive un champ members.json
|
||||
/json-config reset — Remet la config par défaut
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from src.json_export_service import (
|
||||
load_config, save_config, generate_all,
|
||||
generate_servers_json, generate_members_json,
|
||||
preview_servers, preview_members,
|
||||
get_history, log_history,
|
||||
SERVERS_FILE, MEMBERS_FILE, OUTPUT_DIR,
|
||||
DEFAULT_CONFIG,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kuby.website_sync")
|
||||
|
||||
# Listes des champs disponibles (pour les choix des commandes)
|
||||
SERVER_FIELDS = [
|
||||
"id", "name", "icon", "banner", "owner",
|
||||
"memberCount", "description", "createdAt",
|
||||
"boostLevel", "boostCount", "verificationLevel",
|
||||
]
|
||||
MEMBER_FIELDS = [
|
||||
"id", "username", "displayName", "globalName",
|
||||
"avatar", "bio", "roles", "joinedAt", "createdAt",
|
||||
"badges", "accentColor", "isBot",
|
||||
]
|
||||
|
||||
|
||||
class WebsiteSync(commands.Cog, name="Website Sync"):
|
||||
"""Synchronisation JSON pour le site web."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self._scheduler_task: Optional[asyncio.Task] = None
|
||||
self.daily_export.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.daily_export.cancel()
|
||||
|
||||
# ── Tâche de fond : génération (30s pour test) ────────────────────────────
|
||||
@tasks.loop(seconds=30)
|
||||
async def daily_export(self):
|
||||
"""Génère les JSON toutes les 30 secondes (Mode Test)."""
|
||||
logger.info("[JSON Export] Génération automatique (toutes les 30s) déclenchée.")
|
||||
await generate_all(self.bot)
|
||||
|
||||
@daily_export.before_loop
|
||||
async def before_daily_export(self):
|
||||
await self.bot.wait_until_ready()
|
||||
config = load_config()
|
||||
logger.info(
|
||||
f"[JSON Export] Mode Test actif → génération automatique toutes les 30 secondes."
|
||||
)
|
||||
|
||||
# ── Helper : embed de réponse ─────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _embed(title: str, color: discord.Color, description: str = "") -> discord.Embed:
|
||||
e = discord.Embed(title=title, color=color, timestamp=datetime.now(timezone.utc))
|
||||
if description:
|
||||
e.description = description
|
||||
return e
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-generate
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@app_commands.command(name="json-generate", description="Génère manuellement les fichiers JSON du site web")
|
||||
@app_commands.describe(cible="Quoi générer ? (défaut : tout)")
|
||||
@app_commands.choices(cible=[
|
||||
app_commands.Choice(name="Tout (servers + members)", value="all"),
|
||||
app_commands.Choice(name="Serveurs uniquement", value="servers"),
|
||||
app_commands.Choice(name="Membres uniquement", value="members"),
|
||||
])
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def json_generate(self, interaction: discord.Interaction, cible: str = "all"):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
start = datetime.now()
|
||||
embed = self._embed("⏳ Génération en cours...", discord.Color.yellow())
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
try:
|
||||
if cible == "servers":
|
||||
res = await generate_servers_json(self.bot)
|
||||
embed = self._embed("✅ servers.json généré", discord.Color.green())
|
||||
embed.add_field(name="Serveurs", value=str(res["count"]), inline=True)
|
||||
embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
embed.add_field(name="Fichier", value=f"`{SERVERS_FILE}`", inline=False)
|
||||
|
||||
elif cible == "members":
|
||||
res = await generate_members_json(self.bot)
|
||||
embed = self._embed("✅ members.json généré", discord.Color.green())
|
||||
embed.add_field(name="Membres", value=str(res["count"]), inline=True)
|
||||
embed.add_field(name="Serveur", value=res["guildName"], inline=True)
|
||||
embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
embed.add_field(name="Fichier", value=f"`{MEMBERS_FILE}`", inline=False)
|
||||
|
||||
else: # all
|
||||
res = await generate_all(self.bot, trigger="manual")
|
||||
has_errors = bool(res["errors"])
|
||||
embed = self._embed(
|
||||
"⚠️ Génération partielle" if has_errors else "✅ Génération complète",
|
||||
discord.Color.orange() if has_errors else discord.Color.green(),
|
||||
)
|
||||
if res["servers"]:
|
||||
embed.add_field(name="📋 Serveurs", value=f"{res['servers']['count']} serveurs", inline=True)
|
||||
if res["members"]:
|
||||
embed.add_field(name="👥 Membres", value=f"{res['members']['count']} membres", inline=True)
|
||||
embed.add_field(name="Durée totale", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
if has_errors:
|
||||
embed.add_field(name="❌ Erreurs", value="\n".join(res["errors"]), inline=False)
|
||||
embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", discord.Color.red(), f"```{e}```")
|
||||
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-status
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@app_commands.command(name="json-status", description="État des fichiers JSON et prochaine génération auto")
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def json_status(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
embed = self._embed("📊 Statut JSON Export", discord.Color.blurple())
|
||||
|
||||
# servers.json
|
||||
if SERVERS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(SERVERS_FILE.read_text())
|
||||
size = SERVERS_FILE.stat().st_size / 1024
|
||||
gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M")
|
||||
embed.add_field(
|
||||
name="🌐 servers.json",
|
||||
value=f"✅ {data['count']} serveurs — {size:.1f} KB\n📅 {gen_at}",
|
||||
inline=False,
|
||||
)
|
||||
except Exception:
|
||||
embed.add_field(name="🌐 servers.json", value="⚠️ Fichier corrompu", inline=False)
|
||||
else:
|
||||
embed.add_field(name="🌐 servers.json", value="❌ Non généré", inline=False)
|
||||
|
||||
# members.json
|
||||
if MEMBERS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(MEMBERS_FILE.read_text())
|
||||
size = MEMBERS_FILE.stat().st_size / 1024
|
||||
gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M")
|
||||
embed.add_field(
|
||||
name="👥 members.json",
|
||||
value=f"✅ {data['count']} membres ({data['guildName']}) — {size:.1f} KB\n📅 {gen_at}",
|
||||
inline=False,
|
||||
)
|
||||
except Exception:
|
||||
embed.add_field(name="👥 members.json", value="⚠️ Fichier corrompu", inline=False)
|
||||
else:
|
||||
embed.add_field(name="👥 members.json", value="❌ Non généré", inline=False)
|
||||
|
||||
# Prochain run
|
||||
now = datetime.now()
|
||||
next_run = now.replace(hour=config["autoHour"], minute=config["autoMinute"], second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
diff = next_run - now
|
||||
h, m = divmod(int(diff.total_seconds() // 60), 60)
|
||||
in_text = f"dans {h}h {m}min" if h else f"dans {m}min"
|
||||
embed.add_field(
|
||||
name="⏰ Prochaine génération auto",
|
||||
value=f"**{config['autoHour']:02d}:{config['autoMinute']:02d}** — {in_text}\n({next_run.strftime('%d/%m/%Y %H:%M')})",
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-config (groupe)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
json_config_group = app_commands.Group(
|
||||
name="json-config",
|
||||
description="Configure la génération automatique des JSON",
|
||||
default_permissions=discord.Permissions(administrator=True),
|
||||
)
|
||||
|
||||
# ── show ──────────────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="show", description="Affiche la configuration actuelle")
|
||||
async def config_show(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
|
||||
srv_on = ", ".join(k for k, v in config["serverFields"].items() if v) or "_Aucun_"
|
||||
srv_off = ", ".join(k for k, v in config["serverFields"].items() if not v) or "_Aucun_"
|
||||
mbr_on = ", ".join(k for k, v in config["memberFields"].items() if v) or "_Aucun_"
|
||||
mbr_off = ", ".join(k for k, v in config["memberFields"].items() if not v) or "_Aucun_"
|
||||
|
||||
embed = self._embed("⚙️ Configuration JSON Export", discord.Color.blurple())
|
||||
embed.add_field(name="🕐 Génération auto", value=f"{config['autoHour']:02d}:{config['autoMinute']:02d} (heure serveur)", inline=True)
|
||||
embed.add_field(name="🎯 Serveur cible (members)", value=config["targetGuildId"] or "_Non défini_", inline=True)
|
||||
embed.add_field(name="\u200b", value="\u200b", inline=False)
|
||||
embed.add_field(name="✅ servers.json actifs", value=srv_on, inline=False)
|
||||
embed.add_field(name="❌ servers.json inactifs", value=srv_off, inline=False)
|
||||
embed.add_field(name="✅ members.json actifs", value=mbr_on, inline=False)
|
||||
embed.add_field(name="❌ members.json inactifs", value=mbr_off, inline=False)
|
||||
embed.add_field(name="📁 Dossier de sortie", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── set-guild ────────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="set-guild", description="Définit l'ID du serveur cible pour members.json")
|
||||
@app_commands.describe(guild_id="ID du serveur Discord")
|
||||
async def config_set_guild(self, interaction: discord.Interaction, guild_id: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
guild = self.bot.get_guild(int(guild_id)) or await self.bot.fetch_guild(int(guild_id))
|
||||
config = load_config()
|
||||
config["targetGuildId"] = guild_id
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Serveur cible défini", discord.Color.green(),
|
||||
f"**{guild.name}** (`{guild_id}`) sera utilisé pour members.json.",
|
||||
)
|
||||
except Exception:
|
||||
embed = self._embed(
|
||||
"❌ Serveur introuvable", discord.Color.red(),
|
||||
f"Le bot n'est pas dans le serveur `{guild_id}` ou l'ID est invalide.",
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── set-schedule ─────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="set-schedule", description="Définit l'heure de génération automatique quotidienne")
|
||||
@app_commands.describe(heure="Heure (0-23)", minute="Minute (0-59, défaut: 0)")
|
||||
async def config_set_schedule(self, interaction: discord.Interaction, heure: int, minute: int = 0):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
if not (0 <= heure <= 23) or not (0 <= minute <= 59):
|
||||
await interaction.followup.send("❌ Heure invalide.", ephemeral=True)
|
||||
return
|
||||
config = load_config()
|
||||
config["autoHour"] = heure
|
||||
config["autoMinute"] = minute
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Horaire mis à jour", discord.Color.green(),
|
||||
f"Prochaine génération auto : **{heure:02d}:{minute:02d}** chaque jour.",
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── server-field ─────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="server-field", description="Active/désactive un champ dans servers.json")
|
||||
@app_commands.describe(champ="Champ à modifier", actif="true = inclus, false = exclu")
|
||||
@app_commands.choices(champ=[app_commands.Choice(name=f, value=f) for f in SERVER_FIELDS])
|
||||
async def config_server_field(self, interaction: discord.Interaction, champ: str, actif: bool):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
config["serverFields"][champ] = actif
|
||||
save_config(config)
|
||||
state = "inclus ✅" if actif else "exclu ❌"
|
||||
embed = self._embed("✅ Champ servers.json mis à jour", discord.Color.green(),
|
||||
f"`{champ}` est maintenant **{state}** de servers.json.")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── member-field ─────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="member-field", description="Active/désactive un champ dans members.json")
|
||||
@app_commands.describe(champ="Champ à modifier", actif="true = inclus, false = exclu")
|
||||
@app_commands.choices(champ=[app_commands.Choice(name=f, value=f) for f in MEMBER_FIELDS])
|
||||
async def config_member_field(self, interaction: discord.Interaction, champ: str, actif: bool):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
config["memberFields"][champ] = actif
|
||||
save_config(config)
|
||||
state = "inclus ✅" if actif else "exclu ❌"
|
||||
embed = self._embed("✅ Champ members.json mis à jour", discord.Color.green(),
|
||||
f"`{champ}` est maintenant **{state}** de members.json.")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── reset ─────────────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="reset", description="Remet la configuration par défaut")
|
||||
async def config_reset(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
from src.json_export_service import CONFIG_FILE
|
||||
try:
|
||||
CONFIG_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
load_config() # recrée depuis DEFAULT_CONFIG
|
||||
embed = self._embed("✅ Configuration réinitialisée", discord.Color.green(),
|
||||
"Tous les paramètres ont été remis à leurs valeurs par défaut.")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── set-output-dir ────────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="set-output-dir", description="Change le dossier de sortie des fichiers JSON")
|
||||
@app_commands.describe(chemin="Chemin absolu ou relatif vers le dossier (ex: /var/www/site/data)")
|
||||
async def config_set_output_dir(self, interaction: discord.Interaction, chemin: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
from pathlib import Path as _Path
|
||||
p = _Path(chemin)
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
config = load_config()
|
||||
config["outputDir"] = str(p)
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Dossier de sortie mis à jour", discord.Color.green(),
|
||||
f"Les JSON seront désormais écrits dans :\n`{p.resolve()}`",
|
||||
)
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", discord.Color.red(), f"Impossible d'utiliser ce chemin :\n```{e}```")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── add-founder-role ──────────────────────────────────────────────────────
|
||||
@json_config_group.command(name="add-founder-role", description="Ajoute un nom de rôle reconnu comme 'fondateur'")
|
||||
@app_commands.describe(nom="Nom du rôle (insensible à la casse, correspondance partielle)")
|
||||
async def config_add_founder_role(self, interaction: discord.Interaction, nom: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
roles = config.get("founderRoles", [])
|
||||
nom_l = nom.lower().strip()
|
||||
if nom_l in [r.lower() for r in roles]:
|
||||
embed = self._embed("ℹ️ Déjà présent", discord.Color.yellow(),
|
||||
f"`{nom}` est déjà dans la liste des rôles fondateurs.")
|
||||
else:
|
||||
roles.append(nom_l)
|
||||
config["founderRoles"] = roles
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Rôle fondateur ajouté", discord.Color.green(),
|
||||
f"`{nom_l}` ajouté. Liste actuelle : {', '.join(f'`{r}`' for r in roles)}",
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── remove-founder-role ───────────────────────────────────────────────────
|
||||
@json_config_group.command(name="remove-founder-role", description="Retire un nom de rôle fondateur")
|
||||
@app_commands.describe(nom="Nom du rôle à retirer")
|
||||
async def config_remove_founder_role(self, interaction: discord.Interaction, nom: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
roles = config.get("founderRoles", [])
|
||||
nom_l = nom.lower().strip()
|
||||
updated = [r for r in roles if r.lower() != nom_l]
|
||||
if len(updated) == len(roles):
|
||||
embed = self._embed("ℹ️ Introuvable", discord.Color.yellow(),
|
||||
f"`{nom}` n'est pas dans la liste des rôles fondateurs.")
|
||||
else:
|
||||
config["founderRoles"] = updated
|
||||
save_config(config)
|
||||
reste = ', '.join(f'`{r}`' for r in updated) or "_Aucun_"
|
||||
embed = self._embed(
|
||||
"✅ Rôle fondateur retiré", discord.Color.green(),
|
||||
f"`{nom_l}` retiré. Liste actuelle : {reste}",
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-preview
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@app_commands.command(name="json-preview", description="Aperçu des données sans écrire les fichiers")
|
||||
@app_commands.describe(
|
||||
cible="Quoi prévisualiser ?",
|
||||
limite="Nombre d'éléments à afficher (défaut: 3)",
|
||||
)
|
||||
@app_commands.choices(cible=[
|
||||
app_commands.Choice(name="Serveurs", value="servers"),
|
||||
app_commands.Choice(name="Membres", value="members"),
|
||||
])
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def json_preview(self, interaction: discord.Interaction, cible: str = "servers", limite: int = 3):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
limite = max(1, min(limite, 10)) # entre 1 et 10
|
||||
|
||||
try:
|
||||
embed = self._embed(f"🔍 Aperçu — {cible}", discord.Color.blurple())
|
||||
embed.set_footer(text="Aperçu uniquement — aucun fichier écrit")
|
||||
|
||||
if cible == "servers":
|
||||
data = await preview_servers(self.bot, limit=limite)
|
||||
for s in data:
|
||||
val = f"ID: `{s['id']}`"
|
||||
if "memberCount" in s:
|
||||
val += f"\nMembres: {s['memberCount']}"
|
||||
if s.get("icon"):
|
||||
val += f"\nIcon: {s['icon']}"
|
||||
embed.add_field(name=f"🌐 {s['name']}", value=val, inline=False)
|
||||
|
||||
else: # members
|
||||
data = await preview_members(self.bot, limit=limite)
|
||||
embed.description = f"Serveur : **{data['guildName']}**"
|
||||
for m in data["members"]:
|
||||
roles_str = ", ".join(m["roles"][:5]) or "_Aucun_"
|
||||
if len(m["roles"]) > 5:
|
||||
roles_str += f" +{len(m['roles'])-5}"
|
||||
embed.add_field(
|
||||
name=f"👤 {m['displayName']} ({m['username']})",
|
||||
value=f"ID: `{m['id']}`\nRôles: {roles_str}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", discord.Color.red(), f"```{e}```")
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-history
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@app_commands.command(name="json-history", description="Historique des dernières générations automatiques et manuelles")
|
||||
@app_commands.describe(limite="Nombre d'entrées à afficher (défaut: 5, max: 10)")
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def json_history(self, interaction: discord.Interaction, limite: int = 5):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
limite = max(1, min(limite, 10))
|
||||
history = get_history()
|
||||
|
||||
embed = self._embed("📜 Historique des générations JSON", discord.Color.blurple())
|
||||
|
||||
if not history:
|
||||
embed.description = "_Aucune génération enregistrée pour l'instant._"
|
||||
else:
|
||||
for entry in history[:limite]:
|
||||
date_str = datetime.fromisoformat(entry["date"]).strftime("%d/%m/%Y %H:%M")
|
||||
trigger = "🤖 Auto" if entry.get("trigger") == "auto" else "👤 Manuel"
|
||||
status = "✅" if entry.get("success") else "⚠️"
|
||||
|
||||
lines = [f"{trigger} — {date_str}"]
|
||||
if entry.get("serversCount") is not None:
|
||||
lines.append(f"📋 {entry['serversCount']} serveurs")
|
||||
if entry.get("membersCount") is not None:
|
||||
lines.append(f"👥 {entry['membersCount']} membres ({entry.get('guildName', '?')})")
|
||||
if entry.get("errors"):
|
||||
lines.append(f"❌ Erreurs: {'; '.join(entry['errors'])}")
|
||||
|
||||
embed.add_field(
|
||||
name=f"{status} Génération du {date_str}",
|
||||
value="\n".join(lines),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
|
||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
async def setup(bot: commands.Bot):
|
||||
cog = WebsiteSync(bot)
|
||||
await bot.add_cog(cog)
|
||||
# Enregistre le groupe de commandes json-config s'il n'est pas déjà présent
|
||||
if not bot.tree.get_command("json-config"):
|
||||
bot.tree.add_command(cog.json_config_group)
|
||||
logger.info("Cog WebsiteSync chargé.")
|
||||
|
|
@ -7,7 +7,7 @@ from typing import List
|
|||
|
||||
WHITELIST_FILE = "data/whitelist.json"
|
||||
|
||||
class Whitelist(commands.GroupCog, name="whitelist"):
|
||||
class Whitelist(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.whitelist = self.load_whitelist()
|
||||
|
|
@ -32,58 +32,95 @@ class Whitelist(commands.GroupCog, name="whitelist"):
|
|||
self.whitelist[str(guild_id)] = users
|
||||
self.save_whitelist()
|
||||
|
||||
@commands.hybrid_group(name="whitelist", aliases=["wl"], description="Gère la whitelist du serveur")
|
||||
async def whitelist_group(self, ctx):
|
||||
"""Groupe de commandes pour gérer la whitelist"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
|
||||
# ✅ Ajouter un utilisateur
|
||||
@app_commands.command(name="ajouter", description="Ajoute un utilisateur à la whitelist")
|
||||
@whitelist_group.command(name="ajouter", description="Ajoute un utilisateur à la whitelist")
|
||||
@app_commands.describe(utilisateur="L'utilisateur à ajouter")
|
||||
async def ajouter(self, interaction: discord.Interaction, utilisateur: discord.User):
|
||||
guild_id = interaction.guild.id
|
||||
async def ajouter(self, ctx, utilisateur: discord.User):
|
||||
interaction = ctx.interaction
|
||||
guild_id = ctx.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
user_id = str(utilisateur.id)
|
||||
|
||||
if user_id in users:
|
||||
await interaction.response.send_message(f"🔒 {utilisateur.mention} est déjà dans la whitelist.", ephemeral=True)
|
||||
msg = f"🔒 {utilisateur.mention} est déjà dans la whitelist."
|
||||
if interaction:
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
return
|
||||
|
||||
users.append(user_id)
|
||||
self._set_guild_whitelist(guild_id, users)
|
||||
await interaction.response.send_message(f"✅ {utilisateur.mention} a été ajouté à la whitelist.", ephemeral=True)
|
||||
msg = f"✅ {utilisateur.mention} a été ajouté à la whitelist."
|
||||
if interaction:
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
|
||||
# ❌ Retirer un utilisateur
|
||||
@app_commands.command(name="retirer", description="Retire un utilisateur de la whitelist")
|
||||
@whitelist_group.command(name="retirer", description="Retire un utilisateur de la whitelist")
|
||||
@app_commands.describe(utilisateur="L'utilisateur à retirer")
|
||||
async def retirer(self, interaction: discord.Interaction, utilisateur: discord.User):
|
||||
guild_id = interaction.guild.id
|
||||
async def retirer(self, ctx, utilisateur: discord.User):
|
||||
interaction = ctx.interaction
|
||||
guild_id = ctx.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
user_id = str(utilisateur.id)
|
||||
|
||||
if user_id not in users:
|
||||
await interaction.response.send_message(f"⚠️ {utilisateur.mention} n'est pas dans la whitelist.", ephemeral=True)
|
||||
msg = f"⚠️ {utilisateur.mention} n'est pas dans la whitelist."
|
||||
if interaction:
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
return
|
||||
|
||||
users.remove(user_id)
|
||||
self._set_guild_whitelist(guild_id, users)
|
||||
await interaction.response.send_message(f"✅ {utilisateur.mention} a été retiré de la whitelist.", ephemeral=True)
|
||||
msg = f"✅ {utilisateur.mention} a été retiré de la whitelist."
|
||||
if interaction:
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
|
||||
# 📋 Voir la whitelist
|
||||
@app_commands.command(name="liste", description="Affiche les utilisateurs de la whitelist")
|
||||
async def liste(self, interaction: discord.Interaction):
|
||||
guild_id = interaction.guild.id
|
||||
@whitelist_group.command(name="liste", description="Affiche les utilisateurs de la whitelist")
|
||||
async def liste(self, ctx):
|
||||
interaction = ctx.interaction
|
||||
guild_id = ctx.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
|
||||
if not users:
|
||||
await interaction.response.send_message("📭 La whitelist est vide pour ce serveur.", ephemeral=True)
|
||||
msg = "📭 La whitelist est vide pour ce serveur."
|
||||
if interaction:
|
||||
await interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
return
|
||||
|
||||
# Defer if interaction because fetch_user might take time
|
||||
if interaction:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
membres = []
|
||||
for user_id in users:
|
||||
try:
|
||||
utilisateur = await self.bot.fetch_user(int(user_id))
|
||||
membres.append(f"- {utilisateur.mention} ({utilisateur.name}#{utilisateur.discriminator})")
|
||||
membres.append(f"- {utilisateur.mention} ({utilisateur.name})")
|
||||
except discord.NotFound:
|
||||
membres.append(f"- <@{user_id}> (Utilisateur introuvable)")
|
||||
|
||||
texte = "\n".join(membres)
|
||||
await interaction.response.send_message(f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}", ephemeral=True)
|
||||
msg = f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}"
|
||||
if interaction:
|
||||
await interaction.followup.send(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
|
||||
def is_whitelisted(self, guild_id: int, user_id: int) -> bool:
|
||||
"""Check if a user is whitelisted for a specific guild."""
|
||||
|
|
|
|||
6
data/absence_settings.json
Normal file
6
data/absence_settings.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"1369669999345537145": {
|
||||
"absence_channel_id": 1369669999677145103,
|
||||
"absence_role_id": 1369669999345537154
|
||||
}
|
||||
}
|
||||
3
data/absences.json
Normal file
3
data/absences.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1369669999345537145": {}
|
||||
}
|
||||
5
data/convocation_backups.json
Normal file
5
data/convocation_backups.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"1369669999345537145_1337733859814543410": [
|
||||
1370452086193655840
|
||||
]
|
||||
}
|
||||
11
data/convocation_settings.json
Normal file
11
data/convocation_settings.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"1369669999345537145": {
|
||||
"role_id": 1369669999366770723,
|
||||
"log_channel_id": 1369669999677145103,
|
||||
"admin_ids": [
|
||||
353942721997832195,
|
||||
971446412690722826
|
||||
],
|
||||
"attente_id": 1369669999677145099
|
||||
}
|
||||
}
|
||||
37
data/json-export-config.json
Normal file
37
data/json-export-config.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"targetGuildId": "1369669999345537145",
|
||||
"autoHour": 3,
|
||||
"autoMinute": 0,
|
||||
"outputDir": "/home/gameurpro12/Documents/Mes_projets/Site_Omega_Kube/website/assets/data",
|
||||
"founderRoles": [
|
||||
"fondateur",
|
||||
"founder"
|
||||
],
|
||||
"serverFields": {
|
||||
"id": true,
|
||||
"name": true,
|
||||
"icon": true,
|
||||
"banner": true,
|
||||
"owner": true,
|
||||
"memberCount": true,
|
||||
"description": true,
|
||||
"createdAt": true,
|
||||
"boostLevel": false,
|
||||
"boostCount": false,
|
||||
"verificationLevel": false
|
||||
},
|
||||
"memberFields": {
|
||||
"id": true,
|
||||
"username": true,
|
||||
"displayName": true,
|
||||
"globalName": true,
|
||||
"avatar": true,
|
||||
"bio": true,
|
||||
"roles": true,
|
||||
"joinedAt": true,
|
||||
"createdAt": true,
|
||||
"badges": false,
|
||||
"accentColor": false,
|
||||
"isBot": false
|
||||
}
|
||||
}
|
||||
128
data/json-export-history.json
Normal file
128
data/json-export-history.json
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
[
|
||||
{
|
||||
"date": "2026-03-01T00:26:14.746054+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:25:45.072770+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:25:15.071380+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:24:45.388984+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:24:14.975026+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:23:44.713904+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:23:16.510199+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:22:44.966638+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:22:14.857332+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:21:44.958489+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:21:14.844468+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:20:44.959093+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:20:14.948095+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01T00:19:44.835750+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
}
|
||||
]
|
||||
38
data/logs/messages_2026-02-27.json
Normal file
38
data/logs/messages_2026-02-27.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-02-27T16:51:53.636507",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1476970101038125148,
|
||||
"channel_id": 1369669999677145103,
|
||||
"channel_name": "╰📂・rapports",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369769836028231730,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "1369669999677145103",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-27T16:52:08.494696",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1476970163726188639,
|
||||
"channel_id": 1369669999677145103,
|
||||
"channel_name": "╰📂・rapports",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369769836028231730,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "<@&1369669999345537154>",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
380
src/json_export_service.py
Normal file
380
src/json_export_service.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"""
|
||||
Service d'export JSON pour le site web.
|
||||
Génère deux fichiers :
|
||||
- servers.json : tous les serveurs où le bot est présent
|
||||
- members.json : tous les membres (sans bots) d'un serveur cible
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
|
||||
logger = logging.getLogger("kuby.json_export")
|
||||
|
||||
# ─── Chemins ────────────────────────────────────────────────────────────────
|
||||
CONFIG_FILE = Path("data/json-export-config.json")
|
||||
HISTORY_FILE = Path("data/json-export-history.json")
|
||||
|
||||
def _get_output_dir() -> Path:
|
||||
"""Lit le dossier de sortie depuis la config (ou .env en fallback)."""
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
if saved.get("outputDir"):
|
||||
return Path(saved["outputDir"])
|
||||
except Exception:
|
||||
pass
|
||||
return Path(os.getenv("JSON_OUTPUT_DIR", "data/website-json"))
|
||||
|
||||
@property
|
||||
def OUTPUT_DIR() -> Path:
|
||||
return _get_output_dir()
|
||||
|
||||
# Propriétés dynamiques — recalculées à chaque accès
|
||||
def get_output_dir() -> Path:
|
||||
return _get_output_dir()
|
||||
|
||||
def get_servers_file() -> Path:
|
||||
return _get_output_dir() / "servers.json"
|
||||
|
||||
def get_members_file() -> Path:
|
||||
return _get_output_dir() / "members.json"
|
||||
|
||||
# Aliases statiques utilisés dans les imports existants (valeur initiale)
|
||||
OUTPUT_DIR = _get_output_dir()
|
||||
SERVERS_FILE = OUTPUT_DIR / "servers.json"
|
||||
MEMBERS_FILE = OUTPUT_DIR / "members.json"
|
||||
|
||||
# ─── Config par défaut ───────────────────────────────────────────────────────
|
||||
DEFAULT_CONFIG: dict = {
|
||||
# ID du serveur ciblé pour members.json (ne change jamais)
|
||||
"targetGuildId": os.getenv("TARGET_GUILD_ID", ""),
|
||||
|
||||
# Heure de génération automatique quotidienne (format 24h)
|
||||
"autoHour": 3,
|
||||
"autoMinute": 0,
|
||||
|
||||
# Dossier de sortie (vide = utilise JSON_OUTPUT_DIR du .env)
|
||||
"outputDir": os.getenv("JSON_OUTPUT_DIR", "data/website-json"),
|
||||
|
||||
# Noms de rôles reconnus comme "fondateur" (insensible à la casse)
|
||||
"founderRoles": ["fondateur", "founder"],
|
||||
|
||||
# Champs inclus dans servers.json
|
||||
"serverFields": {
|
||||
"id": True,
|
||||
"name": True,
|
||||
"icon": True, # URL pp du serveur
|
||||
"banner": True, # URL bannière
|
||||
"owner": True, # Propriétaire + rôle fondateur
|
||||
"memberCount": True, # Membres humains uniquement
|
||||
"description": True,
|
||||
"createdAt": True,
|
||||
"boostLevel": False,
|
||||
"boostCount": False,
|
||||
"verificationLevel": False,
|
||||
},
|
||||
|
||||
# Champs inclus dans members.json
|
||||
"memberFields": {
|
||||
"id": True,
|
||||
"username": True,
|
||||
"displayName": True, # Pseudo sur le serveur
|
||||
"globalName": True, # Nom d'affichage global
|
||||
"avatar": True, # URL avatar (serveur + global)
|
||||
"bio": True, # Description (toujours null, limite API Discord)
|
||||
"roles": True, # Tous les rôles avec couleur
|
||||
"joinedAt": True, # Date d'arrivée sur le serveur
|
||||
"createdAt": True, # Date de création du compte
|
||||
"badges": False, # Badges du compte
|
||||
"accentColor": False, # Couleur de profil
|
||||
"isBot": False, # Flag bot (les bots sont toujours exclus de la liste)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── Helpers config ──────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict:
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not CONFIG_FILE.exists():
|
||||
save_config(DEFAULT_CONFIG)
|
||||
return dict(DEFAULT_CONFIG)
|
||||
try:
|
||||
saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
# Merge avec les valeurs par défaut pour les nouvelles clés
|
||||
merged = {**DEFAULT_CONFIG, **saved}
|
||||
merged["serverFields"] = {**DEFAULT_CONFIG["serverFields"], **saved.get("serverFields", {})}
|
||||
merged["memberFields"] = {**DEFAULT_CONFIG["memberFields"], **saved.get("memberFields", {})}
|
||||
merged["founderRoles"] = saved.get("founderRoles", DEFAULT_CONFIG["founderRoles"])
|
||||
merged["outputDir"] = saved.get("outputDir", DEFAULT_CONFIG["outputDir"])
|
||||
return merged
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture config : {e}")
|
||||
return dict(DEFAULT_CONFIG)
|
||||
|
||||
|
||||
def save_config(config: dict) -> None:
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
# ─── Historique des générations ──────────────────────────────────────────────
|
||||
MAX_HISTORY = 20 # nombre max d'entrées conservées
|
||||
|
||||
def _load_history() -> list:
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _save_history(history: list) -> None:
|
||||
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.write_text(json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def log_history(result: dict) -> None:
|
||||
"""Ajoute une entrée dans l'historique après une génération."""
|
||||
history = _load_history()
|
||||
entry = {
|
||||
"date": datetime.now(timezone.utc).isoformat(),
|
||||
"trigger": result.get("trigger", "auto"), # "auto" ou "manual"
|
||||
"serversCount": result["servers"]["count"] if result.get("servers") else None,
|
||||
"membersCount": result["members"]["count"] if result.get("members") else None,
|
||||
"guildName": result["members"]["guildName"] if result.get("members") else None,
|
||||
"errors": result.get("errors", []),
|
||||
"success": len(result.get("errors", [])) == 0,
|
||||
}
|
||||
history.insert(0, entry)
|
||||
_save_history(history[:MAX_HISTORY])
|
||||
|
||||
def get_history() -> list:
|
||||
return _load_history()
|
||||
|
||||
|
||||
# ─── Génération servers.json ─────────────────────────────────────────────────
|
||||
|
||||
async def generate_servers_json(bot: discord.Client) -> dict:
|
||||
config = load_config()
|
||||
fields = config["serverFields"]
|
||||
founder_keys = [r.lower() for r in config.get("founderRoles", ["fondateur", "founder"])]
|
||||
out_dir = Path(config["outputDir"])
|
||||
servers_file = out_dir / "servers.json"
|
||||
servers = []
|
||||
|
||||
for guild in bot.guilds:
|
||||
try:
|
||||
guild = await bot.fetch_guild(guild.id, with_counts=False)
|
||||
except Exception:
|
||||
pass # on travaille avec la version cache
|
||||
|
||||
entry = {}
|
||||
|
||||
if fields.get("id"): entry["id"] = str(guild.id)
|
||||
if fields.get("name"): entry["name"] = guild.name
|
||||
if fields.get("icon"): entry["icon"] = str(guild.icon.url) if guild.icon else None
|
||||
if fields.get("banner"): entry["banner"] = str(guild.banner.url) if guild.banner else None
|
||||
if fields.get("description"): entry["description"] = guild.description
|
||||
if fields.get("createdAt"): entry["createdAt"] = guild.created_at.isoformat()
|
||||
if fields.get("boostLevel"): entry["boostLevel"] = guild.premium_tier
|
||||
if fields.get("boostCount"): entry["boostCount"] = guild.premium_subscription_count
|
||||
if fields.get("verificationLevel"):
|
||||
entry["verificationLevel"] = str(guild.verification_level)
|
||||
|
||||
if fields.get("memberCount"):
|
||||
try:
|
||||
members = [m async for m in guild.fetch_members(limit=None)]
|
||||
entry["memberCount"] = sum(1 for m in members if not m.bot)
|
||||
except Exception:
|
||||
entry["memberCount"] = guild.member_count
|
||||
|
||||
if fields.get("owner"):
|
||||
founders = []
|
||||
try:
|
||||
owner = await guild.fetch_member(guild.owner_id)
|
||||
founders.append({
|
||||
"id": str(owner.id),
|
||||
"username": owner.name,
|
||||
"displayName": owner.display_name,
|
||||
"avatar": str(owner.display_avatar.url),
|
||||
"isOwner": True,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Membres avec un rôle dont le nom correspond à founderRoles
|
||||
try:
|
||||
async for member in guild.fetch_members(limit=None):
|
||||
if member.bot or member.id == guild.owner_id:
|
||||
continue
|
||||
for role in member.roles:
|
||||
if any(key in role.name.lower() for key in founder_keys):
|
||||
founders.append({
|
||||
"id": str(member.id),
|
||||
"username": member.name,
|
||||
"displayName": member.display_name,
|
||||
"avatar": str(member.display_avatar.url),
|
||||
"isOwner": False,
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entry["founders"] = founders
|
||||
|
||||
servers.append(entry)
|
||||
|
||||
output = {
|
||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(servers),
|
||||
"servers": servers,
|
||||
}
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
servers_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
logger.info(f"servers.json généré → {servers_file} ({len(servers)} serveurs)")
|
||||
return output
|
||||
|
||||
|
||||
# ─── Génération members.json ─────────────────────────────────────────────────
|
||||
|
||||
async def generate_members_json(bot: discord.Client, guild_id: Optional[int] = None) -> dict:
|
||||
config = load_config()
|
||||
fields = config["memberFields"]
|
||||
target_id = guild_id or (int(config["targetGuildId"]) if config["targetGuildId"] else None)
|
||||
out_dir = Path(config["outputDir"])
|
||||
members_file = out_dir / "members.json"
|
||||
|
||||
if not target_id:
|
||||
raise ValueError("Aucun targetGuildId configuré. Utilise /json-config set-guild <id>")
|
||||
|
||||
guild = bot.get_guild(target_id) or await bot.fetch_guild(target_id)
|
||||
if not guild:
|
||||
raise ValueError(f"Serveur introuvable : {target_id}")
|
||||
|
||||
members_list = []
|
||||
async for member in guild.fetch_members(limit=None):
|
||||
if member.bot:
|
||||
continue
|
||||
|
||||
entry = {}
|
||||
|
||||
if fields.get("id"): entry["id"] = str(member.id)
|
||||
if fields.get("username"): entry["username"] = member.name
|
||||
if fields.get("displayName"): entry["displayName"] = member.display_name
|
||||
if fields.get("globalName"): entry["globalName"] = member.global_name or member.name
|
||||
if fields.get("avatar"):
|
||||
entry["avatar"] = str(member.display_avatar.url)
|
||||
entry["globalAvatar"] = str(member.avatar.url) if member.avatar else str(member.default_avatar.url)
|
||||
if fields.get("joinedAt"): entry["joinedAt"] = member.joined_at.isoformat() if member.joined_at else None
|
||||
if fields.get("createdAt"): entry["createdAt"] = member.created_at.isoformat()
|
||||
if fields.get("isBot"): entry["isBot"] = member.bot
|
||||
if fields.get("bio"): entry["bio"] = None # Non accessible via l'API Bot officielle
|
||||
if fields.get("accentColor"): entry["accentColor"] = None # Nécessite fetch user OAuth2
|
||||
|
||||
if fields.get("roles"):
|
||||
entry["roles"] = [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"name": r.name,
|
||||
"color": str(r.color),
|
||||
"position": r.position,
|
||||
"hoist": r.hoist,
|
||||
}
|
||||
for r in sorted(member.roles, key=lambda r: -r.position)
|
||||
if r.id != guild.id # exclut @everyone
|
||||
]
|
||||
|
||||
if fields.get("badges"):
|
||||
entry["badges"] = [flag.name for flag in member.public_flags.all()]
|
||||
|
||||
members_list.append(entry)
|
||||
|
||||
output = {
|
||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"guildId": str(guild.id),
|
||||
"guildName": guild.name,
|
||||
"count": len(members_list),
|
||||
"members": members_list,
|
||||
}
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
members_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
logger.info(f"members.json généré → {members_file} ({len(members_list)} membres)")
|
||||
return output
|
||||
|
||||
|
||||
# ─── Génération complète ─────────────────────────────────────────────────────
|
||||
|
||||
async def generate_all(bot: discord.Client, trigger: str = "auto") -> dict:
|
||||
results = {"servers": None, "members": None, "errors": [], "trigger": trigger}
|
||||
try:
|
||||
results["servers"] = await generate_servers_json(bot)
|
||||
except Exception as e:
|
||||
results["errors"].append(f"servers: {e}")
|
||||
logger.error(f"Erreur génération servers.json : {e}")
|
||||
try:
|
||||
results["members"] = await generate_members_json(bot)
|
||||
except Exception as e:
|
||||
results["errors"].append(f"members: {e}")
|
||||
logger.error(f"Erreur génération members.json : {e}")
|
||||
|
||||
log_history(results)
|
||||
return results
|
||||
|
||||
|
||||
# ─── Prévisualisation (sans écriture fichier) ────────────────────────────────
|
||||
|
||||
async def preview_servers(bot: discord.Client, limit: int = 3) -> list:
|
||||
"""Retourne un aperçu de N serveurs sans écrire le fichier."""
|
||||
config = load_config()
|
||||
fields = config["serverFields"]
|
||||
founder_keys = [r.lower() for r in config.get("founderRoles", ["fondateur"])]
|
||||
results = []
|
||||
|
||||
for guild in list(bot.guilds)[:limit]:
|
||||
entry = {
|
||||
"id": str(guild.id),
|
||||
"name": guild.name,
|
||||
"icon": str(guild.icon.url) if guild.icon else None,
|
||||
}
|
||||
if fields.get("memberCount"):
|
||||
try:
|
||||
members = [m async for m in guild.fetch_members(limit=None)]
|
||||
entry["memberCount"] = sum(1 for m in members if not m.bot)
|
||||
except Exception:
|
||||
entry["memberCount"] = guild.member_count
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
async def preview_members(bot: discord.Client, limit: int = 5) -> dict:
|
||||
"""Retourne un aperçu de N membres sans écrire le fichier."""
|
||||
config = load_config()
|
||||
target_id = int(config["targetGuildId"]) if config["targetGuildId"] else None
|
||||
if not target_id:
|
||||
raise ValueError("Aucun targetGuildId configuré.")
|
||||
|
||||
guild = bot.get_guild(target_id) or await bot.fetch_guild(target_id)
|
||||
preview = []
|
||||
count = 0
|
||||
async for member in guild.fetch_members(limit=None):
|
||||
if member.bot:
|
||||
continue
|
||||
preview.append({
|
||||
"id": str(member.id),
|
||||
"username": member.name,
|
||||
"displayName": member.display_name,
|
||||
"avatar": str(member.display_avatar.url),
|
||||
"roles": [r.name for r in member.roles if r.id != guild.id],
|
||||
})
|
||||
count += 1
|
||||
if count >= limit:
|
||||
break
|
||||
return {"guildName": guild.name, "totalFetched": limit, "members": preview}
|
||||
Loading…
Add table
Add a link
Reference in a new issue