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