Mise à jour du système de whitelist, sécurité et logging

This commit is contained in:
Mathis 2026-03-29 19:29:17 +02:00
parent d82f704c75
commit 1e384032be
7 changed files with 199 additions and 145 deletions

View file

@ -0,0 +1,15 @@
# À RÉGLER - Session Suivante (Ticket Whitelist)
### Bug Identifié
La commande **"validé"** par message (on_message) n'est pas détectée ou ne réagit pas si le ticket a été refermé puis réouvert avec le statut "Ouvert".
### État Actuel
- Le bouton "Fermer" fonctionne et détecte l'ID via Regex.
- Le bouton "Réouvrir" synchronise maintenant les rôles (Écrit/Oral/Accepté).
- Le design des MPs (Orange/Vert) est restauré conforme aux captures.
- **Reste** : Vérifier pourquoi `on_message` ignore parfois le message "validé" (probablement un problème de sujet (topic) ou de permissions au moment de la lecture).
### Prochaine Étape
1. Lancer le bot.
2. Ouvrir/Réouvrir un ticket.
3. Taper "validé" et observer les messages d'erreur explicites (❌) que j'ai ajoutés.

View file

@ -9,10 +9,12 @@ import os
import asyncio
kuby_logger = logging.getLogger("KubyBot")
REPORTS_FILE = "data/gitlab_reports.json"
# Utilisation de chemins absolus pour la persistance après reboot
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json")
def save_report(issue_iid, user_id):
os.makedirs("data", exist_ok=True)
os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True)
try:
if os.path.exists(REPORTS_FILE):
with open(REPORTS_FILE, "r") as f:
@ -152,7 +154,23 @@ class BugReport(commands.Cog):
self.bot.loop.create_task(cleanup())
async def handle_bot_event(self, request):
# Vérification de la sécurité (Secret Token GitLab)
webhook_secret = os.getenv("GITLAB_WEBHOOK_SECRET")
if webhook_secret:
provided_token = request.headers.get("X-Gitlab-Token")
if provided_token != webhook_secret:
kuby_logger.warning("Unauthorised webhook attempt detected (invalid X-Gitlab-Token).")
return web.json_response({"status": "unauthorised"}, status=401)
try:
# On attend que le bot soit prêt si on vient de rebooter
if not self.bot.is_ready():
kuby_logger.info("Webhook received but bot is not ready yet. Waiting up to 10s...")
try:
await asyncio.wait_for(self.bot.wait_until_ready(), timeout=10.0)
except asyncio.TimeoutError:
kuby_logger.warning("Bot still not ready after 10s. Proceeding anyway (best effort).")
payload = await request.json()
event_type = payload.get("object_kind")

View file

@ -960,53 +960,49 @@ class Security(commands.Cog):
Commande principale pour ouvrir l'interface de sécurité.
Accessible uniquement aux administrateurs et propriétaires whitelistés.
"""
# On diffère la réponse immédiatement car le chargement des réglages peut prendre du temps
await interaction.response.defer(ephemeral=True)
# Vérification des permissions
if not is_immune(interaction.guild, interaction.user):
if interaction.response.is_done():
return await interaction.followup.send(
"❌ **Accès refusé**\n\nVous devez être administrateur, propriétaire du serveur, ou être dans la whitelist pour accéder à ce panneau.",
ephemeral=True
)
return await interaction.response.send_message(
return await interaction.followup.send(
"❌ **Accès refusé**\n\nVous devez être administrateur, propriétaire du serveur, ou être dans la whitelist pour accéder à ce panneau.",
ephemeral=True
)
# Chargement des paramètres
settings = load_settings(interaction.guild.id)
try:
# Chargement des paramètres
settings = load_settings(interaction.guild.id)
# Création de la vue
view = SecurityView(interaction, settings, bot=self.bot)
embed = view.create_main_embed(interaction.user)
# Création de la vue
view = SecurityView(interaction, settings, bot=self.bot)
embed = view.create_main_embed(interaction.user)
if interaction.response.is_done():
await interaction.followup.send(
embed=embed,
view=view,
ephemeral=True
)
else:
await interaction.response.send_message(
embed=embed,
view=view,
except Exception as e:
await interaction.followup.send(
f"❌ Une erreur est survenue lors de l'ouverture du panneau : {e}",
ephemeral=True
)
@security.error
async def security_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Gestion des erreurs de la commande security"""
# Ne pas envoyer de message si l'interaction a déjà été traitée
if interaction.response.is_done():
return
# Utilisation de followup si l'interaction est déjà différée ou répondue
send_method = interaction.followup.send if interaction.response.is_done() else interaction.response.send_message
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
await send_method(
"❌ Permissions insuffisantes.",
ephemeral=True
)
else:
await interaction.response.send_message(
f"❌ Une erreur est survenue.",
await send_method(
f"❌ Une erreur est survenue lors de l'exécution de la commande : {error}",
ephemeral=True
)

View file

@ -1,4 +1,5 @@
import discord
import re
from discord.ext import commands
from discord import app_commands, ui
import json
@ -313,26 +314,28 @@ class TicketStep3(discord.ui.Modal, title="📋 Développement du Personnage"):
except discord.errors.NotFound:
await interaction.followup.send(f"✅ Ticket ouvert : {ticket_channel.mention}", ephemeral=True)
class TicketManagementView(discord.ui.view.View):
class TicketManagementView(discord.ui.View):
"""Vue pour gérer le ticket avec les boutons Fermer, Réouvrir, Claim et Supprimer"""
def __init__(self):
super().__init__(timeout=None)
async def get_ticket_owner(self, interaction: discord.Interaction):
"""Récupère le propriétaire du ticket via le topic du salon (robuste)"""
if interaction.channel.topic and "Ticket de" in interaction.channel.topic:
"""Récupère le propriétaire du ticket via le topic du salon (robuste via regex)"""
import re
topic = interaction.channel.topic
if not topic:
return None
match = re.search(r"(\d{17,20})", topic)
if match:
owner_id = int(match.group(1))
try:
user_id_str = interaction.channel.topic.replace("Ticket de ", "").strip()
owner_id = int(user_id_str)
member = interaction.guild.get_member(owner_id)
if not member:
try:
member = await interaction.guild.fetch_member(owner_id)
except (discord.NotFound, discord.HTTPException):
return None
member = await interaction.guild.fetch_member(owner_id)
return member
except (ValueError, IndexError):
except:
return None
return None
@ -390,26 +393,28 @@ class TicketManagementView(discord.ui.view.View):
kuby_logger.error(f"Erreur lors du claim du ticket: {e}")
await interaction.followup.send(f"❌ Erreur lors du claim : {e}")
class ClosedTicketView(discord.ui.view.View):
class ClosedTicketView(discord.ui.View):
"""Vue pour les tickets fermés avec les boutons Réouvrir et Supprimer"""
def __init__(self):
super().__init__(timeout=None)
async def get_ticket_owner(self, interaction: discord.Interaction):
"""Récupère le propriétaire du ticket via le topic du salon (robuste)"""
if interaction.channel.topic and "Ticket de" in interaction.channel.topic:
"""Récupère le propriétaire du ticket via le topic du salon (robuste via regex)"""
import re
topic = interaction.channel.topic
if not topic:
return None
match = re.search(r"(\d{17,20})", topic)
if match:
owner_id = int(match.group(1))
try:
user_id_str = interaction.channel.topic.replace("Ticket de ", "").strip()
owner_id = int(user_id_str)
member = interaction.guild.get_member(owner_id)
if not member:
try:
member = await interaction.guild.fetch_member(owner_id)
except (discord.NotFound, discord.HTTPException):
return None
member = await interaction.guild.fetch_member(owner_id)
return member
except (ValueError, IndexError):
except:
return None
return None
@ -443,7 +448,7 @@ class ClosedTicketView(discord.ui.view.View):
kuby_logger.error(f"Erreur lors de la préparation de réouverture: {e}")
await interaction.followup.send(f"❌ Erreur : {e}", ephemeral=True)
class ReopenStatusView(discord.ui.view.View):
class ReopenStatusView(discord.ui.View):
"""Vue pour choisir le statut lors d'une réouverture"""
def __init__(self, owner: discord.Member):
super().__init__(timeout=180)
@ -464,16 +469,48 @@ class ReopenStatusView(discord.ui.view.View):
async def apply_status(self, interaction: discord.Interaction, prefix: str):
await interaction.response.defer(ephemeral=True)
try:
settings = load_settings(interaction.guild.id)
candidature_a_faire_role_id = settings.get("candidature_a_faire_role_id")
attente_oral_role_id = settings.get("attente_oral_role_id")
citoyen_role_id = settings.get("citoyen_role_id")
# On récupère les objets rôles
role_écrit = interaction.guild.get_role(int(candidature_a_faire_role_id)) if candidature_a_faire_role_id else None
role_oral = interaction.guild.get_role(int(attente_oral_role_id)) if attente_oral_role_id else None
role_citoyen = interaction.guild.get_role(int(citoyen_role_id)) if citoyen_role_id else None
# Synchronisation des RÔLES en fonction du statut choisi
roles_to_remove = []
role_to_add = None
if prefix == "ouvert":
role_to_add = role_écrit
roles_to_remove = [r for r in [role_oral, role_citoyen] if r]
elif prefix == "oral-à-faire":
role_to_add = role_oral
roles_to_remove = [r for r in [role_écrit, role_citoyen] if r]
elif prefix == "accepté":
role_to_add = role_citoyen
roles_to_remove = [r for r in [role_écrit, role_oral] if r]
# Mise à jour des rôles sur le membre
if roles_to_remove:
await self.owner.remove_roles(*roles_to_remove, reason="Réouverture du ticket - Maj Statut")
if role_to_add:
await self.owner.add_roles(role_to_add, reason="Réouverture du ticket - Maj Statut")
# Mise à jour du SALON
await interaction.channel.set_permissions(self.owner, read_messages=True, send_messages=True)
new_name = f"{prefix}-{self.owner.display_name}".replace(" ", "-").lower()[:100]
await interaction.channel.edit(name=new_name)
await interaction.channel.edit(name=new_name, topic=f"Ticket de {self.owner.id}")
new_view = TicketManagementView()
await interaction.channel.send(f"✅ Ticket réouvert par {interaction.user.mention} (Statut: {prefix}).", view=new_view)
await interaction.followup.send(f"✅ Statut {prefix} appliqué.", ephemeral=True)
await interaction.channel.send(f"✅ Ticket réouvert par {interaction.user.mention} (Statut: **{prefix}**).", view=new_view)
await interaction.followup.send(f"✅ Statut **{prefix}** appliqué et rôles synchronisés.", ephemeral=True)
self.stop()
except Exception as e:
await interaction.followup.send(f"❌ Erreur : {e}", ephemeral=True)
kuby_logger.error(f"Erreur lors de la réouverture/synchro: {e}")
await interaction.followup.send(f"❌ Erreur système : {e}", ephemeral=True)
@discord.ui.button(label="❌ Supprimer le Ticket", style=discord.ButtonStyle.danger, custom_id="delete_ticket")
async def delete_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
@ -565,8 +602,12 @@ class Ticket(commands.Cog):
if message.guild is None or message.author.bot:
return
# Détection du format du topic du ticket
if not message.channel.topic or not message.channel.topic.startswith("Ticket de"):
# Détection du format du topic du ticket via regex (robuste)
if not message.channel.topic:
return
match = re.search(r"(\d{17,20})", message.channel.topic)
if not match:
return
# On ne traite que si le message contient "validé"
@ -585,12 +626,12 @@ class Ticket(commands.Cog):
return
try:
# Extraction propre de l'ID utilisateur depuis le topic
# L'ID est déjà trouvé par le regex ci-dessus
user_id = int(match.group(1))
try:
user_id_str = message.channel.topic.replace("Ticket de ", "").strip()
user = await message.guild.fetch_member(int(user_id_str))
except (ValueError, IndexError, discord.NotFound, discord.HTTPException):
kuby_logger.warning(f"Impossible de trouver l'utilisateur du ticket via le topic: {message.channel.topic}")
user = await message.guild.fetch_member(user_id)
except (discord.NotFound, discord.HTTPException):
await message.channel.send("❌ Utilisateur introuvable. A-t-il quitté le serveur ?", delete_after=10)
return
attente_oral_role_id = settings.get("attente_oral_role_id")
@ -605,7 +646,7 @@ class Ticket(commands.Cog):
candidature_a_faire_role = message.guild.get_role(int(candidature_a_faire_role_id))
citoyen_role = message.guild.get_role(int(citoyen_role_id))
if attente_oral_role in user.roles:
if (attente_oral_role and attente_oral_role in user.roles) or "oral-à-faire" in message.channel.name.lower():
# DEUXIÈME VALIDATION -> ACCEPTÉ / CITOYEN
if attente_oral_role: await user.remove_roles(attente_oral_role)
if citoyen_role: await user.add_roles(citoyen_role)

View file

@ -64,11 +64,11 @@ class WebsiteSync(commands.Cog, name="Website Sync"):
def cog_unload(self):
self.daily_export.cancel()
# ── Tâche de fond : génération (30s pour test) ────────────────────────────
@tasks.loop(seconds=30)
# ── Tâche de fond : génération (1 heure) ────────────────────────────
@tasks.loop(hours=1)
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.")
"""Génère les JSON toutes les heures."""
logger.info("[JSON Export] Génération automatique déclenchée.")
await generate_all(self.bot)
@daily_export.before_loop

View file

@ -34,7 +34,7 @@ class AdvancedLogger:
"details": details or {}
}
self._write_log("messages", log_data)
asyncio.create_task(self._write_log_async("messages", log_data))
def log_role_event(self, member: discord.Member, role: discord.Role, event_type: str):
"""Log role changes (added/removed)"""
@ -49,7 +49,7 @@ class AdvancedLogger:
"role_permissions": role.permissions.value
}
self._write_log("roles", log_data)
asyncio.create_task(self._write_log_async("roles", log_data))
def log_voice_event(self, member: discord.Member, channel: discord.VoiceChannel, event_type: str):
"""Log voice channel events (join/leave)"""
@ -63,7 +63,7 @@ class AdvancedLogger:
"channel_type": str(channel.type)
}
self._write_log("voice", log_data)
asyncio.create_task(self._write_log_async("voice", log_data))
def log_member_leave(self, member: discord.Member):
"""Log when a member leaves the server"""
@ -85,10 +85,10 @@ class AdvancedLogger:
}
# Save to member-specific log
self._write_member_log(member.id, log_data)
asyncio.create_task(self._write_member_log_async(member.id, log_data))
# Also save to general logs
self._write_log("member_events", log_data)
asyncio.create_task(self._write_log_async("member_events", log_data))
def log_member_join(self, member: discord.Member):
"""Log when a member joins the server"""
@ -101,10 +101,13 @@ class AdvancedLogger:
"member_avatar": str(member.avatar.url) if member.avatar else None
}
self._write_log("member_events", log_data)
asyncio.create_task(self._write_log_async("member_events", log_data))
def check_previous_roles(self, member_id: int) -> Optional[Dict]:
"""Check if member has previous role data when rejoining"""
# This one stays synchronous for now as it's called in on_member_join and needs immediate result
# but since it only reads ONE file, it's less problematic than writing to cumulative logs.
# Format update: it now needs to read NDJSON.
member_log_file = f"{self.member_logs_dir}/{member_id}.json"
if not os.path.exists(member_log_file):
@ -112,7 +115,8 @@ class AdvancedLogger:
try:
with open(member_log_file, 'r', encoding='utf-8') as f:
logs = json.load(f)
# Read line by line for NDJSON
logs = [json.loads(line) for line in f if line.strip()]
# Find the most recent leave event
leave_events = [log for log in logs if log.get("event_type") == "member_leave"]
@ -123,71 +127,39 @@ class AdvancedLogger:
kuby_logger.error(f"Error checking previous roles for {member_id}: {e}")
return None
def _write_log(self, log_type: str, data: Dict):
"""Write log data to appropriate file safely with atomic write"""
date_str = datetime.now().strftime("%Y-%m-%d")
log_file = f"{self.logs_dir}/{log_type}_{date_str}.json"
temp_file = f"{log_file}.tmp"
existing_logs = []
async def _write_log_async(self, log_type: str, data: Dict):
"""Asynchronous wrapper for log writing"""
return await asyncio.to_thread(self._write_log_sync, log_type, data)
async def _write_member_log_async(self, member_id: int, data: Dict):
"""Asynchronous wrapper for member log writing"""
return await asyncio.to_thread(self._write_member_log_sync, member_id, data)
def _write_log_sync(self, log_type: str, data: Dict):
"""Write log data to appropriate file using NDJSON (Append-only)"""
try:
if os.path.exists(log_file):
with open(log_file, 'r', encoding='utf-8') as f:
try:
existing_logs = json.load(f)
except json.JSONDecodeError as e:
# Handle corrupted file by backing it up and starting fresh
corrupted_backup = f"{log_file}.corrupted"
os.rename(log_file, corrupted_backup)
kuby_logger.warning(f"Corrupted log file detected and backed up to {corrupted_backup}: {e}. Starting new log.")
existing_logs = []
date_str = datetime.now().strftime("%Y-%m-%d")
log_file = f"{self.logs_dir}/{log_type}_{date_str}.json"
existing_logs.append(data)
# Atomic write using a temporary file
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(existing_logs, f, indent=2, ensure_ascii=False)
os.replace(temp_file, log_file)
# Simple append in NDJSON format
with open(log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(data, ensure_ascii=False) + "\n")
except Exception as e:
kuby_logger.error(f"Error writing log for {log_type}: {e}")
if os.path.exists(temp_file):
try: os.remove(temp_file)
except: pass
# We don't use kuby_logger here to avoid infinite recursion if nested
print(f"CRITICAL ERROR in AdvancedLogger._write_log_sync: {e}")
def _write_member_log(self, member_id: int, data: Dict):
"""Write member-specific log safely with atomic write"""
member_log_file = f"{self.member_logs_dir}/{member_id}.json"
temp_file = f"{member_log_file}.tmp"
existing_logs = []
def _write_member_log_sync(self, member_id: int, data: Dict):
"""Write member-specific log using NDJSON (Append-only)"""
try:
if os.path.exists(member_log_file):
with open(member_log_file, 'r', encoding='utf-8') as f:
try:
existing_logs = json.load(f)
except json.JSONDecodeError as e:
# Handle corrupted file by backing it up and starting fresh
corrupted_backup = f"{member_log_file}.corrupted"
os.rename(member_log_file, corrupted_backup)
kuby_logger.warning(f"Corrupted member log detected and backed up to {corrupted_backup}: {e}. Starting new log.")
existing_logs = []
member_log_file = f"{self.member_logs_dir}/{member_id}.json"
existing_logs.append(data)
# Atomic write using a temporary file
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(existing_logs, f, indent=2, ensure_ascii=False)
os.replace(temp_file, member_log_file)
with open(member_log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(data, ensure_ascii=False) + "\n")
except Exception as e:
kuby_logger.error(f"Error writing member log for {member_id}: {e}")
if os.path.exists(temp_file):
try: os.remove(temp_file)
except: pass
print(f"CRITICAL ERROR in AdvancedLogger._write_member_log_sync: {e}")
async def send_leave_notification(self, member: discord.Member, channel: discord.TextChannel):
"""Send notification when member leaves"""

View file

@ -188,10 +188,10 @@ async def generate_servers_json(bot: discord.Client) -> dict:
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:
# Use cache if available (intents.members is True), otherwise fallback to count
if guild.members:
entry["memberCount"] = sum(1 for m in guild.members if not m.bot)
else:
entry["memberCount"] = guild.member_count
if fields.get("owner"):
@ -209,22 +209,27 @@ async def generate_servers_json(bot: discord.Client) -> dict:
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
# Use cache if possible
members_to_check = guild.members if guild.members else []
if not members_to_check:
try:
members_to_check = [m async for m in guild.fetch_members(limit=None)]
except Exception:
pass
for member in members_to_check:
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
entry["founders"] = founders
@ -259,7 +264,14 @@ async def generate_members_json(bot: discord.Client, guild_id: Optional[int] = N
raise ValueError(f"Serveur introuvable : {target_id}")
members_list = []
async for member in guild.fetch_members(limit=None):
# Use cached members if available, or fetch if not
source = guild.members if guild.members else []
if not source:
async for m in guild.fetch_members(limit=None):
if not m.bot:
source.append(m)
for member in source:
if member.bot:
continue