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:
Lucas Durand 2026-03-01 01:35:20 +01:00
commit 9974a2b6b3
13 changed files with 1936 additions and 18 deletions

5
bot.py
View file

@ -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
View 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
View 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
View 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é.")

View file

@ -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."""

View file

@ -0,0 +1,6 @@
{
"1369669999345537145": {
"absence_channel_id": 1369669999677145103,
"absence_role_id": 1369669999345537154
}
}

3
data/absences.json Normal file
View file

@ -0,0 +1,3 @@
{
"1369669999345537145": {}
}

View file

@ -0,0 +1,5 @@
{
"1369669999345537145_1337733859814543410": [
1370452086193655840
]
}

View file

@ -0,0 +1,11 @@
{
"1369669999345537145": {
"role_id": 1369669999366770723,
"log_channel_id": 1369669999677145103,
"admin_ids": [
353942721997832195,
971446412690722826
],
"attente_id": 1369669999677145099
}
}

View 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
}
}

View 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
}
]

View 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
View file

@ -0,0 +1,380 @@
"""
Service d'export JSON pour le site web.
Génère deux fichiers :
- servers.json : tous les serveurs 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}