diff --git a/commandes/PENDING_TASKS.md b/commandes/PENDING_TASKS.md new file mode 100644 index 0000000..7533628 --- /dev/null +++ b/commandes/PENDING_TASKS.md @@ -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. diff --git a/commandes/bug_report.py b/commandes/bug_report.py index eea1af8..729a197 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -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") diff --git a/commandes/security.py b/commandes/security.py index 6305efa..5a461a0 100755 --- a/commandes/security.py +++ b/commandes/security.py @@ -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 ) diff --git a/commandes/ticket_whitelist.py b/commandes/ticket_whitelist.py index 4bb97c0..e8f3a26 100644 --- a/commandes/ticket_whitelist.py +++ b/commandes/ticket_whitelist.py @@ -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) diff --git a/commandes/website_sync.py b/commandes/website_sync.py index 78aad56..4fdc55e 100644 --- a/commandes/website_sync.py +++ b/commandes/website_sync.py @@ -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 diff --git a/src/advanced_logger.py b/src/advanced_logger.py index 085371c..e8a34fb 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -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""" diff --git a/src/json_export_service.py b/src/json_export_service.py index d25fd38..bf7210d 100644 --- a/src/json_export_service.py +++ b/src/json_export_service.py @@ -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