From 8efd0dc61c8538e840cf168a890b8a26a97ae246 Mon Sep 17 00:00:00 2001 From: Lowei Date: Tue, 19 May 2026 21:03:03 +0200 Subject: [PATCH 01/23] Fix: retirer saut de ligne parasite dans la notif commentaire GitLab --- commandes/bug_report.py | 1 - 1 file changed, 1 deletion(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 9c7ae2e..22cfd27 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -389,7 +389,6 @@ class BugReport(commands.Cog): ) author_name = payload.get("user", {}).get("name", "Développeur") - try: components = [ disnake.ui.Container( From 761e7c0e1620ea3e9e93bd90505875b42a4f4b3c Mon Sep 17 00:00:00 2001 From: Lowei Date: Tue, 19 May 2026 21:19:43 +0200 Subject: [PATCH 02/23] Debug GitLab note DM --- TODO.md | 19 +++++++++++-------- commandes/bug_report.py | 28 ++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index e8df080..4266820 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,12 @@ -# TODO - Panel sanctions (components V2) +# TODO - Corrections GitLab -> MP commentaire + +- [ ] Ajouter des logs détaillés dans `commandes/bug_report.py` pour vérifier : + - [ ] réception webhook (object_kind) + - [ ] issue_iid extrait + - [ ] présence de `system` et filtrage + - [ ] chargement du user_id depuis `data/gitlab_reports.json` + - [ ] tentative d’envoi DM (user.id) +- [ ] Ajouter un fallback si `note_attr.note/body` n’est pas présent. +- [ ] Redéployer (commit + git push + restart bot/PM2 si nécessaire). +- [ ] Vérifier avec un vrai commentaire GitLab. -- [ ] Revoir `commandes/moderation.py` : ajouter `panel_channel_id` dans la table `mod_config` -- [ ] Mettre à jour `/modconfig` avec un nouveau bouton/sélecteur “Salon sanctions” (ChannelSelect) (style components V2, callbacks dédiés) -- [ ] Ajouter un formatter “panneau sanctions” (liste bullets + citations/raison + date + modérateur + durée si TIMEOUT) -- [ ] Ajouter une fonction `send_sanction_panel(...)` et l’appeler dans `warn`, `timeout`, `kick`, `ban` (push automatique) -- [ ] Enrichir `/modpanel` pour afficher les sanctions actives récentes “proprement” (et garder l’UI actuelle) -- [ ] Nettoyer les callbacks (supprimer les `lambda` dans `ModConfigView` si présents) -- [ ] Tester localement : `/modconfig` -> config channel, puis exécuter des sanctions et vérifier l’affichage dans le salon diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 22cfd27..a361670 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -96,7 +96,7 @@ class BugReportModal(disnake.ui.Modal): await interaction.edit_original_response( content=( "✅ Votre bug a été signalé avec succès ! " - "Gameur a bien été averti et le bug sera réglé dans la prochaine mise à jour. " + "Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. " f"[Voir l'issue]({result.get('web_url')})" ) ) @@ -259,6 +259,19 @@ class BugReport(commands.Cog): if not user: return web.json_response({"status": "user not found"}, status=200) + # DEBUG: log brute payload minimal pour comprendre pourquoi aucun DM n'est envoyé + kuby_logger.info( + f"[GitLab webhook] event_type={event_type} issue_iid={issue_iid} " + f"tracked_user_id={user_id} payload_keys={list(payload.keys())[:20]}" + ) + + if event_type == "note": + note_obj_attrs = payload.get("object_attributes") or {} + kuby_logger.info( + f"[GitLab note] system={note_obj_attrs.get('system', False)} " + f"note_len={len(str(note_obj_attrs.get('note') or note_obj_attrs.get('body') or payload.get('note') or ''))}" + ) + app_info = await self.bot.application_info() dev = app_info.owner issue_title = payload.get("object_attributes", {}).get("title", f"#{issue_iid}") @@ -378,6 +391,11 @@ class BugReport(commands.Cog): note_attr = payload.get("object_attributes", {}) is_system = note_attr.get("system", False) + kuby_logger.info( + f"[GitLab note] dispatch started issue_iid={issue_iid} is_system={is_system} " + f"note_field_present={bool(note_attr.get('note') or note_attr.get('body'))}" + ) + if not is_system: # GitLab peut envoyer des champs différents selon version/config note_body = ( @@ -421,7 +439,10 @@ class BugReport(commands.Cog): f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." ) except Exception as e: - kuby_logger.error(f"Erreur inattendue dans la notification de commentaire : {e}") + kuby_logger.error( + f"Erreur inattendue dans la notification de commentaire : {e}", + exc_info=True, + ) return web.json_response({"status": "success"}, status=200) @@ -463,5 +484,4 @@ class BugReport(commands.Cog): def setup(bot): - bot.add_cog(BugReport(bot)) - + bot.add_cog(BugReport(bot)) \ No newline at end of file From 088ff1e849fbafaea1fe19f1bb3412f94e3f2fb2 Mon Sep 17 00:00:00 2001 From: Lowei Date: Tue, 19 May 2026 23:01:34 +0200 Subject: [PATCH 03/23] Debug GitLab note DM v2 --- commandes/gitlab_integration.py | 213 ++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 commandes/gitlab_integration.py diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py new file mode 100644 index 0000000..74ac655 --- /dev/null +++ b/commandes/gitlab_integration.py @@ -0,0 +1,213 @@ +import os +import json +import asyncio +from datetime import datetime, timezone +from typing import Dict, Any, List + +import aiohttp +import disnake +from disnake.ext import commands, tasks + +# Environment variables for GitLab access +GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") +GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path + +if not GITLAB_TOKEN or not GITLAB_PROJECT_ID: + raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID") + +# File to persist issue tracking information +ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") + +def load_issue_tracking() -> Dict[str, Any]: + if os.path.exists(ISSUE_TRACK_FILE): + with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: + return json.load(f) + return {} + +def save_issue_tracking(data: Dict[str, Any]) -> None: + with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +class GitLabIntegration(commands.Cog): + """Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.issue_data: Dict[str, Any] = load_issue_tracking() # key: issue_iid (str) -> {"requester_id": int, "last_note_id": int} + self.check_new_notes.start() + + def cog_unload(self): + self.check_new_notes.cancel() + + async def _create_gitlab_issue(self, title: str, description: str, labels: List[str]) -> Dict[str, Any]: + """Create an issue on GitLab and return the JSON response.""" + url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" + headers = { + "PRIVATE-TOKEN": GITLAB_TOKEN, + "Content-Type": "application/json", + } + payload = { + "title": title, + "description": description, + "labels": ",".join(labels), + } + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as resp: + resp.raise_for_status() + return await resp.json() + + async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: + """Fetch all notes (comments) for a given issue.""" + url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes" + headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]): + """Send a private DM to the requester informing them of a new comment.""" + user = await self.bot.fetch_user(requester_id) + author = note.get("author", {}).get("name", "Someone") + body = note.get("body", "") + embed = disnake.Embed( + title=f"Nouveau commentaire sur votre demande GitLab (#{issue_iid})", + description=body, + color=disnake.Color.blurple(), + timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc), + ) + embed.set_footer(text=f"Commentaire de {author}") + try: + await user.send(embed=embed) + except disnake.HTTPException: + # The user may have DMs disabled; ignore silently. + pass + + @tasks.loop(minutes=1) + async def check_new_notes(self): + """Periodic task that checks for new notes on tracked issues and notifies requesters.""" + for issue_iid_str, info in list(self.issue_data.items()): + issue_iid = int(issue_iid_str) + try: + notes = await self._get_issue_notes(issue_iid) + except Exception as exc: + # Skip this issue if we cannot fetch notes. + continue + # Find the newest note ID + if not notes: + continue + latest_note = max(notes, key=lambda n: n["id"]) + last_known_id = info.get("last_note_id", 0) + if latest_note["id"] > last_known_id: + # New comment detected + await self._notify_requester(info["requester_id"], issue_iid, latest_note) + # Update stored last_note_id + self.issue_data[issue_iid_str]["last_note_id"] = latest_note["id"] + save_issue_tracking(self.issue_data) + + # ---------- Slash command: /suggerer_fonctionnalite ---------- + @commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet") + async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction): + await inter.response.send_modal(modal=FeatureModal(self)) + + # ---------- Slash command: /signaler_bug ---------- + @commands.slash_command(name="signaler_bug", description="Signaler un bug du projet") + async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): + await inter.response.send_modal(modal=BugModal(self)) + +# ---------- Modals ---------- +class FeatureModal(disnake.Modal): + def __init__(self, cog: GitLabIntegration): + components = [ + disnake.ui.TextInput( + label="Titre de la fonctionnalité", + custom_id="title", + style=disnake.TextInputStyle.short, + max_length=100, + required=True, + ), + disnake.ui.TextInput( + label="Description détaillée", + custom_id="description", + style=disnake.TextInputStyle.paragraph, + max_length=2000, + required=True, + ), + ] + super().__init__( + title="Suggestion de fonctionnalité", + custom_id="feature_modal", + components=components, + ) + self.cog = cog + + async def callback(self, inter: disnake.ModalInteraction): + title = inter.text_values["title"] + description = inter.text_values["description"] + await inter.response.defer(ephemeral=True) + try: + issue = await self.cog._create_gitlab_issue( + title=title, + description=description, + labels=["feature", "discord-bot"], + ) + # Store tracking info + issue_iid = str(issue["iid"]) + self.cog.issue_data[issue_iid] = { + "requester_id": inter.author.id, + "last_note_id": 0, + "type": "feature", + } + save_issue_tracking(self.cog.issue_data) + await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + except Exception as e: + await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}") + +class BugModal(disnake.Modal): + def __init__(self, cog: GitLabIntegration): + components = [ + disnake.ui.TextInput( + label="Titre du bug", + custom_id="title", + style=disnake.TextInputStyle.short, + max_length=100, + required=True, + ), + disnake.ui.TextInput( + label="Description et étapes pour reproduire", + custom_id="description", + style=disnake.TextInputStyle.paragraph, + max_length=2000, + required=True, + ), + ] + super().__init__( + title="Signalement de bug", + custom_id="bug_modal", + components=components, + ) + self.cog = cog + + async def callback(self, inter: disnake.ModalInteraction): + title = inter.text_values["title"] + description = inter.text_values["description"] + await inter.response.defer(ephemeral=True) + try: + issue = await self.cog._create_gitlab_issue( + title=title, + description=description, + labels=["bug", "discord-bot"], + ) + issue_iid = str(issue["iid"]) + self.cog.issue_data[issue_iid] = { + "requester_id": inter.author.id, + "last_note_id": 0, + "type": "bug", + } + save_issue_tracking(self.cog.issue_data) + await inter.edit_original_message(content=f"✅ Bug créé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + except Exception as e: + await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}") + +def setup(bot: commands.Bot): + bot.add_cog(GitLabIntegration(bot)) From db3d15b127d9587f8110bda2c1f063e9447f1621 Mon Sep 17 00:00:00 2001 From: Lowei Date: Tue, 19 May 2026 23:06:43 +0200 Subject: [PATCH 04/23] Debug GitLab note DM v3 --- commandes/gitlab_integration.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py index 74ac655..d9569b9 100644 --- a/commandes/gitlab_integration.py +++ b/commandes/gitlab_integration.py @@ -111,7 +111,15 @@ class GitLabIntegration(commands.Cog): await inter.response.send_modal(modal=FeatureModal(self)) # ---------- Slash command: /signaler_bug ---------- - @commands.slash_command(name="signaler_bug", description="Signaler un bug du projet") + @commands.slash_command(name="notify_deploy", description="Envoyer un DM de confirmation du déploiement") + async def notify_deploy(self, inter: disnake.ApplicationCommandInteraction): + """DM the user that the deployment is done.""" + try: + await inter.author.send("✅ C'est fait ! Le code a été déployé sur la branche `main`.") + await inter.response.send_message("✅ DM de confirmation envoyé.", ephemeral=True) + except disnake.HTTPException: + await inter.response.send_message("⚠️ Impossible d'envoyer le DM (DMs désactivés).", ephemeral=True) + async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=BugModal(self)) From 8dab5bbd822830efc048c50f339d6f2c3a1292d9 Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 14:30:31 +0200 Subject: [PATCH 05/23] Debug GitLab note DM v4 --- commandes/bug_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index a361670..0d16d48 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -425,7 +425,7 @@ class BugReport(commands.Cog): ] try: - await user.send(components=components) + await user.send(content=" ", components=components) kuby_logger.info( f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" ) From edf3aada73eb4ad3bcf22661aeca2ba8d9c19c68 Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 14:48:39 +0200 Subject: [PATCH 06/23] Debug GitLab note DM v5 --- TODO.md | 2 +- commandes/bug_report.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 4266820..1c13aa6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TODO - Corrections GitLab -> MP commentaire - [ ] Ajouter des logs détaillés dans `commandes/bug_report.py` pour vérifier : - - [ ] réception webhook (object_kind) + - [X] réception webhook (object_kind) déjà existant puisque qu'il y a les logs des webhooks sur gitlab - [ ] issue_iid extrait - [ ] présence de `system` et filtrage - [ ] chargement du user_id depuis `data/gitlab_reports.json` diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 0d16d48..a361670 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -425,7 +425,7 @@ class BugReport(commands.Cog): ] try: - await user.send(content=" ", components=components) + await user.send(components=components) kuby_logger.info( f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" ) From 4dad380a507fa9a23ac4b49c7af132accb7fbf2f Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 15:23:09 +0200 Subject: [PATCH 07/23] Debug GitLab note DM v6 --- TODO.md | 19 +++++++++---------- commandes/bug_report.py | 13 +++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index 1c13aa6..18cf720 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,11 @@ -# TODO - Corrections GitLab -> MP commentaire +# TODO - Kuby + +- [x] Identifier le cog qui démarre le serveur aiohttp sur 127.0.0.1:5001 (bug_report.py) +- [x] Remplacer l’async hook `cog_load` par un démarrage différé via `__init__` + `bot.loop.create_task` (disnake 2.12.0) +- [x] Renommer la logique serveur en `_start_webhook_server` +- [x] Attendre `await bot.wait_until_ready()` avant d’ouvrir le port +- [x] Conserver le retry bind port 5001 (OSError errno 98) +- [ ] Redémarrer le bot +- [ ] Vérifier dans les logs: "Internal Bot Webhook Server started on 127.0.0.1:5001" -- [ ] Ajouter des logs détaillés dans `commandes/bug_report.py` pour vérifier : - - [X] réception webhook (object_kind) déjà existant puisque qu'il y a les logs des webhooks sur gitlab - - [ ] issue_iid extrait - - [ ] présence de `system` et filtrage - - [ ] chargement du user_id depuis `data/gitlab_reports.json` - - [ ] tentative d’envoi DM (user.id) -- [ ] Ajouter un fallback si `note_attr.note/body` n’est pas présent. -- [ ] Redéployer (commit + git push + restart bot/PM2 si nécessaire). -- [ ] Vérifier avec un vrai commentaire GitLab. diff --git a/commandes/bug_report.py b/commandes/bug_report.py index a361670..5e0bffd 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -165,15 +165,20 @@ class FeatureSuggestionModal(disnake.ui.Modal): class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot + # disnake 2.12: `cog_load` async n'est pas appelé comme dans discord.py. + # On lance le serveur aiohttp de façon différée. + self.bot.loop.create_task(self._start_webhook_server()) + + async def _start_webhook_server(self): + await self.bot.wait_until_ready() - async def cog_load(self): self.web_app = web.Application() self.web_app.router.add_post('/bot_event', self.handle_bot_event) self.runner = web.AppRunner(self.web_app) await self.runner.setup() - max_retries = 5 - retry_delay = 2 + max_retries = 10 + retry_delay = 5 for attempt in range(1, max_retries + 1): try: @@ -184,7 +189,7 @@ class BugReport(commands.Cog): ) return except OSError as e: - if e.errno == 98: # Address already in use + if getattr(e, 'errno', None) == 98: # Address already in use if attempt < max_retries: kuby_logger.warning( f"Port 5001 already in use, retrying in {retry_delay}s... ({attempt}/{max_retries})" From 2a3d57159a1af96c8ecd2291b14d05372bb23e08 Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 15:34:34 +0200 Subject: [PATCH 08/23] Debug GitLab note DM v7 --- bot.py | 103 ++++++++++++--------------------------------------------- 1 file changed, 22 insertions(+), 81 deletions(-) diff --git a/bot.py b/bot.py index 9f94e5c..bc36f3d 100755 --- a/bot.py +++ b/bot.py @@ -13,7 +13,6 @@ import asyncio from src.logger import kuby_logger, log_performance import logging from src.advanced_logger import AdvancedLogger -import subprocess SENSITIVE_PERMISSIONS = { "administrator", "manage_guild", "manage_roles", "manage_channels", @@ -30,7 +29,6 @@ class RoleApprovalView(disnake.ui.View): @disnake.ui.button(label="Accepter", style=disnake.ButtonStyle.green, emoji="✅") async def accept(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): - # Check if member still in guild guild = self.member.guild current_member = guild.get_member(self.member.id) if not current_member: @@ -41,8 +39,7 @@ class RoleApprovalView(disnake.ui.View): try: await current_member.add_roles(*self.roles, reason="Restauration sécurisée approuvée par le propriétaire") await interaction.response.send_message(f"✅ Rôles sensibles restaurés pour {self.member.mention}.", ephemeral=True) - - # Update original message + components = [ disnake.ui.Container( disnake.ui.TextDisplay(f"✅ Vous avez approuvé la restauration des rôles pour {self.member.mention}."), @@ -57,8 +54,7 @@ class RoleApprovalView(disnake.ui.View): @disnake.ui.button(label="Refuser", style=disnake.ButtonStyle.red, emoji="✖️") async def deny(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await interaction.response.send_message(f"❌ Vous avez refusé la restauration pour {self.member.mention}.", ephemeral=True) - - # Update original message + components = [ disnake.ui.Container( disnake.ui.TextDisplay(f"❌ Vous avez refusé la restauration des rôles pour {self.member.mention}."), @@ -68,6 +64,7 @@ class RoleApprovalView(disnake.ui.View): await interaction.edit_original_response(embed=None, components=components, view=None) self.stop() + # Récupération et validation de l'ID d'application application_id_raw = os.getenv("APPLICATION_ID") kuby_logger.debug(f"Raw APPLICATION_ID: {application_id_raw}") @@ -80,11 +77,12 @@ except ValueError: # Configuration des intents Disnake intents = disnake.Intents.default() -intents.message_content = True -intents.members = True +intents.message_content = True +intents.members = True intents.voice_states = True kuby_logger.debug(f"Disnake intents configured: {intents}") + class MyBot(commands.Bot): def __init__(self): kuby_logger.debug("Initializing MyBot class") @@ -95,34 +93,16 @@ class MyBot(commands.Bot): command_sync_flags=commands.CommandSyncFlags.all() ) kuby_logger.info("MyBot instance created successfully") - + # Initialize advanced logger self.advanced_logger = AdvancedLogger(self) kuby_logger.info("✅ Advanced logger initialized") - # Start the Flask Webhook Server - self.flask_process = None - try: - flask_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "serveur_flask.py") - if os.path.exists(flask_path): - self.flask_process = subprocess.Popen( - [sys.executable, flask_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=os.path.dirname(flask_path) - ) - kuby_logger.info(f"✅ Serveur Flask démarré (PID: {self.flask_process.pid})") - else: - kuby_logger.warning(f"⚠️ Fichier serveur_flask.py introuvable") - except Exception as e: - kuby_logger.error(f"❌ Impossible de démarrer le serveur Flask: {e}") - + # Flask est géré par PM2 (kuby-flask), pas besoin de le lancer ici async def do_async_setup(self): - # --- INITIALIZATION LOGIC (Called before start() in kuby.py) --- kuby_logger.info("🚀 Configuration initiale du bot Kuby...") - # Charger les extensions extensions = [ "commandes.whitelist", "commandes.whitelist_monitor", @@ -154,31 +134,6 @@ class MyBot(commands.Bot): except Exception as e: kuby_logger.error(f"❌ Erreur lors du chargement de {extension} : {e}", exc_info=True) - # --- SYSTÈME DE WHITELIST (DÉDIÉ SLASH COMMANDS) --- - async def global_slash_whitelist_check(inter: disnake.ApplicationCommandInteraction): - try: - if await self.is_owner(inter.author): - return True - command_name = inter.data.name - if command_name in ["ticketconfig", "ticketpanel", "ticket_config", "ticket_panel", "ticket_whitelist_config", "invitations"]: - return True - whitelist_cog = self.get_cog("WhitelistMonitor") - if not whitelist_cog: - return True - is_whitelisted = whitelist_cog.is_whitelisted(inter.guild.id, inter.author.id) - if not is_whitelisted: - kuby_logger.info(f"🚫 [Check] Accès refusé : {inter.author} sur /{command_name}") - msg = "❌ Vous n'êtes pas autorisé à utiliser cette commande. Veuillez demander à être ajouté à la whitelist." - if not inter.response.is_done(): - await inter.response.send_message(msg, ephemeral=True) - return False - return True - except Exception as e: - kuby_logger.error(f"⚠️ [Check] Erreur critique: {e}", exc_info=True) - return True - - # self.add_app_command_check(global_slash_whitelist_check) - # --- LOGGING DES INTERACTIONS --- @self.listen("on_interaction") async def log_interaction(inter: disnake.Interaction): @@ -187,17 +142,6 @@ class MyBot(commands.Bot): kuby_logger.info("✅ Configuration terminée") - async def close(self): - if hasattr(self, 'flask_process') and self.flask_process: - kuby_logger.info("Arrêt du serveur Flask interne...") - self.flask_process.terminate() - try: - self.flask_process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.flask_process.kill() - await super().close() - - # --- ÉVÉNEMENTS RESTAURÉS --- async def on_ready(self): kuby_logger.info(f"🤖 Bot connecté : {self.user} (ID : {self.user.id})") kuby_logger.info(f"📊 Serveurs : {len(self.guilds)}") @@ -224,23 +168,16 @@ class MyBot(commands.Bot): roles_to_add = [guild.get_role(rid) for rid in previous_roles.get("role_ids", [])] roles_to_add = [r for r in roles_to_add if r and r < guild.me.top_role] if roles_to_add: - # Separate roles into safe and sensitive safe_roles = [] sensitive_roles = [] - + for role in roles_to_add: - is_sensitive = False - for perm in SENSITIVE_PERMISSIONS: - if getattr(role.permissions, perm, False): - is_sensitive = True - break - + is_sensitive = any(getattr(role.permissions, perm, False) for perm in SENSITIVE_PERMISSIONS) if is_sensitive: sensitive_roles.append(role) else: safe_roles.append(role) - # Restore safe roles automatically if safe_roles: retries = 3 for i in range(retries): @@ -250,8 +187,8 @@ class MyBot(commands.Bot): break except (disnake.HTTPException, disnake.GatewayNotFound, asyncio.TimeoutError) as e: is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504] - if not isinstance(e, disnake.HTTPException): is_transient = True - + if not isinstance(e, disnake.HTTPException): + is_transient = True if is_transient and i < retries - 1: await asyncio.sleep((i + 1) * 2) continue @@ -261,7 +198,6 @@ class MyBot(commands.Bot): kuby_logger.error(f"Error restoring safe roles for {member}: {e}") break - # Handle sensitive roles with owner approval if sensitive_roles: try: owner = guild.owner or await guild.fetch_member(guild.owner_id) @@ -298,19 +234,23 @@ class MyBot(commands.Bot): kuby_logger.error(f"Error in leave notification: {e}") async def on_message_delete(self, message): - if message.author.bot: return + if message.author.bot: + return if hasattr(self, 'advanced_logger'): self.advanced_logger.log_message_event(message, "deleted") async def on_message_edit(self, before, after): - if before.author.bot or before.content == after.content: return + if before.author.bot or before.content == after.content: + return if hasattr(self, 'advanced_logger'): self.advanced_logger.log_message_event(before, "edited", { - "before_content": before.content, "after_content": after.content + "before_content": before.content, + "after_content": after.content }) async def on_voice_state_update(self, member, before, after): - if member.bot: return + if member.bot: + return if before.channel != after.channel and hasattr(self, 'advanced_logger'): if before.channel is None: self.advanced_logger.log_voice_event(member, after.channel, "joined") @@ -322,9 +262,10 @@ class MyBot(commands.Bot): async def on_command_error(self, ctx, error): if isinstance(error, commands.CommandNotFound): - return # Ignorer les erreurs "Command not found" pour éviter de polluer les logs (ex: !!) + return if isinstance(error, commands.CheckFailure): return kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True) + bot = MyBot() \ No newline at end of file From fbd225ea6d613774bb376f056528e70451f66104 Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 15:49:40 +0200 Subject: [PATCH 09/23] Debug GitLab note DM v7 --- commandes/bug_report.py | 89 ++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 54 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 5e0bffd..3eefee4 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -165,8 +165,9 @@ class FeatureSuggestionModal(disnake.ui.Modal): class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot - # disnake 2.12: `cog_load` async n'est pas appelé comme dans discord.py. - # On lance le serveur aiohttp de façon différée. + + async def cog_load(self): + # Disnake appelle automatiquement cette méthode dès que la boucle d'événements est active. self.bot.loop.create_task(self._start_webhook_server()) async def _start_webhook_server(self): @@ -301,24 +302,18 @@ class BugReport(commands.Cog): current_labels_str = "Mis à jour" try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "🛠️ Mise à jour de votre signalement !", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe." - ), - disnake.ui.TextDisplay( - f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}" - ), - ) - ] + embed = disnake.Embed( + title="🛠️ Mise à jour de votre signalement !", + description=( + f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.\n\n" + f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}" + ), + color=disnake.Color.blue() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -350,25 +345,19 @@ class BugReport(commands.Cog): if is_closing_now: try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "🛠️ Bug / Suggestion Terminé(e) !", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu." - ), - disnake.ui.TextDisplay( - "🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !" - ), - disnake.ui.TextDisplay("✨ Merci pour votre aide !"), - ) - ] + embed = disnake.Embed( + title="🛠️ Bug / Suggestion Terminé(e) !", + description=( + f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.\n\n" + f"🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !\n" + f"✨ Merci pour votre aide !" + ), + color=disnake.Color.green() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -402,7 +391,6 @@ class BugReport(commands.Cog): ) if not is_system: - # GitLab peut envoyer des champs différents selon version/config note_body = ( note_attr.get("note") or note_attr.get("body") @@ -413,24 +401,19 @@ class BugReport(commands.Cog): author_name = payload.get("user", {}).get("name", "Développeur") try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "💬 Nouveau commentaire sur votre signalement", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Le développeur a répondu à votre rapport **{issue_title}** :" - ), - disnake.ui.TextDisplay(f">>> {note_body}"), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay(f"✍️ **Par :** {author_name}"), - ) - ] + embed = disnake.Embed( + title="💬 Nouveau commentaire sur votre signalement", + description=( + f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n" + f">>> {note_body}\n\n" + f"✍️ **Par :** {author_name}" + ), + color=disnake.Color.orange() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -467,14 +450,12 @@ class BugReport(commands.Cog): choices={"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"}, ), ): - # Simulate app_commands.Choice behavior for existing code from collections import namedtuple Choice = namedtuple('Choice', ['name', 'value']) mapping = {"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"} reverse = {v: k for k, v in mapping.items()} - # priority vient du value GitLab (Low/Normal/High/Urgent) p_name = reverse.get(priority, "Normal") choice = Choice(name=p_name, value=priority) From 01dd4979d872209de3d93f5a209cab04617e73f0 Mon Sep 17 00:00:00 2001 From: Mathis Date: Wed, 20 May 2026 16:43:54 +0200 Subject: [PATCH 10/23] Debug GitLab note DM v8 --- bot.py | 5 +---- kuby.py | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot.py b/bot.py index bc36f3d..6883ee5 100755 --- a/bot.py +++ b/bot.py @@ -265,7 +265,4 @@ class MyBot(commands.Bot): return if isinstance(error, commands.CheckFailure): return - kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True) - - -bot = MyBot() \ No newline at end of file + kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True) \ No newline at end of file diff --git a/kuby.py b/kuby.py index ff01fee..d73ca48 100755 --- a/kuby.py +++ b/kuby.py @@ -2,7 +2,7 @@ import asyncio import os import sys from dotenv import load_dotenv -from bot import bot +from bot import MyBot # Modification ici : on importe la classe from src.logger import kuby_logger, log_performance kuby_logger.info("🚀 Lancement du bot Kuby...") @@ -28,6 +28,9 @@ kuby_logger.info("✅ Token Discord chargé avec succès") async def main(): kuby_logger.info("🔄 Démarrage de la connexion Discord...") try: + # Modification majeure : l'objet bot est instancié ici, au sein de la boucle active + bot = MyBot() + await bot.do_async_setup() kuby_logger.info("🔗 Connexion établie avec Discord...") await bot.start(TOKEN) From 9546e130010530d331d5ef260aff136c2ea814da Mon Sep 17 00:00:00 2001 From: Lowei Date: Thu, 21 May 2026 22:48:19 +0200 Subject: [PATCH 11/23] =?UTF-8?q?fix(bug-report):=20=C3=A9viter=20la=20bou?= =?UTF-8?q?cle=20infinie=20quand=20le=20dev=20a=20ses=20DMs=20bloqu=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si user.send() et dev.send() échouent tous deux avec Forbidden, l'exception était rattrapée par le handler externe qui réessayait d'envoyer à dev. On ignore désormais silencieusement l'échec. Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 3eefee4..bab31e3 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -423,9 +423,12 @@ class BugReport(commands.Cog): ) except (disnake.Forbidden, disnake.HTTPException): if dev and dev.id != user.id: - await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." - ) + try: + await dev.send( + f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." + ) + except (disnake.Forbidden, disnake.HTTPException): + pass # On ne peut pas envoyer au dev non plus, on abandonne except Exception as e: kuby_logger.error( f"Erreur inattendue dans la notification de commentaire : {e}", From db4876e0855cdcaabc2cdb60386a09b4f428d257 Mon Sep 17 00:00:00 2001 From: Lowei Date: Thu, 21 May 2026 23:02:00 +0200 Subject: [PATCH 12/23] =?UTF-8?q?feat(moderation):=20DM=20d=C3=A9taill?= =?UTF-8?q?=C3=A9=20lors=20des=20sanctions=20+=20commande=20/sanctions=20+?= =?UTF-8?q?=20salon=20de=20contestation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout d'un DM formaté (action, modérateur, durée, raison, lien contestation, lien règlement) - Envoi automatique du DM pour warn, timeout, kick, ban - Nouvelle commande /sanctions pour voir l'historique d'un membre - Nouveau champ appeal_channel_id pour le salon de contestation - Mise à jour de /modconfig avec bouton de configuration du salon de contestation Co-Authored-By: Claude Opus 4.6 --- commandes/moderation.py | 117 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/commandes/moderation.py b/commandes/moderation.py index f6b5e87..7e2d65f 100644 --- a/commandes/moderation.py +++ b/commandes/moderation.py @@ -4,6 +4,7 @@ import sqlite3 import os from datetime import datetime, timedelta import re +from src.logger import kuby_logger class ModReasonModal(disnake.ui.Modal): def __init__(self, cog, action_type, member): @@ -99,6 +100,7 @@ class Moderation(commands.Cog): guild_id INTEGER PRIMARY KEY, log_channel_id INTEGER, board_channel_id INTEGER, + appeal_channel_id INTEGER, warn_limit_timeout INTEGER DEFAULT 3, warn_limit_kick INTEGER DEFAULT 5, warn_limit_ban INTEGER DEFAULT 10, @@ -106,6 +108,8 @@ class Moderation(commands.Cog): )''') try: cursor.execute("ALTER TABLE mod_config ADD COLUMN board_channel_id INTEGER") except: pass + try: cursor.execute("ALTER TABLE mod_config ADD COLUMN appeal_channel_id INTEGER") + except: pass conn.commit(); conn.close() def parse_duration(self, duration_str: str) -> int: @@ -119,6 +123,60 @@ class Moderation(commands.Cog): def _type_to_emoji(self, s_type: str) -> str: return {"WARN": "⚠️", "TIMEOUT": "⏳", "KICK": "👢", "BAN": "🔨"}.get(s_type, "🛡️") + def _type_to_action_name(self, s_type: str) -> str: + return {"WARN": "Avertissement", "TIMEOUT": "Timeout", "KICK": "Expulsion", "BAN": "Ban"}.get(s_type, "Sanction") + + def _format_duration(self, seconds: int) -> str: + if seconds >= 86400: + return f"{seconds // 86400} jour(s)" + elif seconds >= 3600: + return f"{seconds // 3600} heure(s)" + elif seconds >= 60: + return f"{seconds // 60} minute(s)" + return f"{seconds} seconde(s)" + + async def send_sanction_dm(self, user, s_type: str, reason: str, moderator, guild, duration: int = None) -> bool: + """Envoie un DM détaillé à l'utilisateur sanctionné. Retourne True si envoyé.""" + from commandes.modules_security.rules import load_settings as load_rules_settings + + emoji = self._type_to_emoji(s_type) + action_name = self._type_to_action_name(s_type) + + conn = sqlite3.connect(self.db_path); cursor = conn.cursor() + cursor.execute('SELECT appeal_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,)) + res = cursor.fetchone() + conn.close() + + rules_settings = load_rules_settings(guild.id) + appeal_channel = guild.get_channel(res[0]) if res and res[0] else None + rules_channel = guild.get_channel(rules_settings.get("rules_channel_id")) if rules_settings.get("rules_channel_id") else None + + embed = disnake.Embed( + title=f"{emoji} {action_name} sur **{guild.name}**", + color=disnake.Color.red() if s_type in ("BAN", "KICK") else disnake.Color.orange() + ) + embed.set_thumbnail(url=guild.icon.url if guild.icon else None) + + embed.add_field(name="📋 Action", value=action_name, inline=True) + embed.add_field(name="👤 Modérateur", value=f"**{moderator}**", inline=True) + if duration: + embed.add_field(name="⏱️ Durée", value=self._format_duration(duration), inline=True) + + embed.add_field(name="📝 Raison", value=f"*{reason if reason else 'Aucune raison spécifiée'}*", inline=False) + + if appeal_channel: + embed.add_field(name="🔗 Contester cette sanction", value=f"Vous pouvez contester cette mesure dans {appeal_channel.mention}.", inline=False) + + if rules_channel: + embed.add_field(name="📜 Règlement", value=f"[Voir le règlement](https://discord.com/channels/{guild.id}/{rules_channel.id})", inline=False) + + try: + await user.send(embed=embed) + return True + except disnake.Forbidden: + kuby_logger.warning(f"Impossible d'envoyer un DM à {user} (DMs bloqués)") + return False + def build_sanction_board_components(self, *, s_type: str, user, moderator, reason, timestamp, s_id, duration=None) -> list: ts_int = int(datetime.fromisoformat(str(timestamp)).timestamp()) if not isinstance(timestamp, datetime) else int(timestamp.timestamp()) emoji = self._type_to_emoji(s_type) @@ -203,20 +261,22 @@ class Moderation(commands.Cog): async def send_modconfig_v2(self, interaction: disnake.Interaction, edit=False): conn = sqlite3.connect(self.db_path); cursor = conn.cursor() - cursor.execute('SELECT log_channel_id, board_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,)) + cursor.execute('SELECT log_channel_id, board_channel_id, appeal_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,)) res = cursor.fetchone(); conn.close() if not res: return - + children = [ disnake.ui.Section("⚙️ Configuration Modération", accessory=disnake.ui.Thumbnail(interaction.guild.icon.url if interaction.guild.icon else None)), disnake.ui.Separator(divider=True), disnake.ui.TextDisplay(f"📁 **Logs internes :** <#{res[0]}>" if res[0] else "📁 **Logs :** Non défini"), disnake.ui.TextDisplay(f"📢 **Affichage public :** <#{res[1]}>" if res[1] else "📢 **Affichage :** Non défini"), - disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[2]} | K:{res[3]} | B:{res[4]}"), - disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[5]}s"), + disnake.ui.TextDisplay(f"🔗 **Contestation :** <#{res[2]}>" if res[2] else "🔗 **Contestation :** Non défini"), + disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[3]} | K:{res[4]} | B:{res[5]}"), + disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[6]}s"), disnake.ui.Separator(divider=True), disnake.ui.Section("📁 Salon des logs", accessory=disnake.ui.Button(label="Logs", style=disnake.ButtonStyle.primary, custom_id="modcfg_logs")), disnake.ui.Section("📢 Salon d'affichage", accessory=disnake.ui.Button(label="Public", style=disnake.ButtonStyle.primary, custom_id="modcfg_board")), + disnake.ui.Section("🔗 Salon de contestation", accessory=disnake.ui.Button(label="Contester", style=disnake.ButtonStyle.primary, custom_id="modcfg_appeal")), disnake.ui.Section("⚖️ Modifier paliers", accessory=disnake.ui.Button(label="Paliers", style=disnake.ButtonStyle.secondary, custom_id="modcfg_limits")), disnake.ui.Section("⏱️ Durée timeout", accessory=disnake.ui.Button(label="Durée", style=disnake.ButtonStyle.secondary, custom_id="modcfg_duration")) ] @@ -229,6 +289,31 @@ class Moderation(commands.Cog): else: await interaction.response.send_message(components=components, ephemeral=True) + @commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre") + @commands.has_permissions(moderate_members=True) + async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member): + conn = sqlite3.connect(self.db_path); cursor = conn.cursor() + cursor.execute('SELECT type, reason, timestamp, moderator_id, duration FROM sanctions WHERE guild_id = ? AND user_id = ? ORDER BY timestamp DESC LIMIT 10', (interaction.guild_id, member.id)) + rows = cursor.fetchall(); conn.close() + + if not rows: + return await interaction.response.send_message(f"Aucune sanction trouvée pour {member.mention}.", ephemeral=True) + + children = [ + disnake.ui.Section(f"📋 Historique : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)), + disnake.ui.Separator(divider=True), + ] + + for t, r, ts, mod_id, dur in rows: + ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp()) + moderator = interaction.guild.get_member(mod_id) + mod_name = moderator.name if moderator else f"Modérateur #{mod_id}" + dur_str = f" ({self._format_duration(dur)})" if dur else "" + children.append(disnake.ui.TextDisplay(f"{self._type_to_emoji(t)} **{t}{dur_str}** par **{mod_name}** — {r or 'Sans raison'} ()")) + + components = [disnake.ui.Container(*children)] + await interaction.response.send_message(components=components, ephemeral=True) + @commands.slash_command(name="modconfig", description="Configuration de la modération") @commands.has_permissions(administrator=True) async def modconfig(self, interaction: disnake.ApplicationCommandInteraction): @@ -297,6 +382,18 @@ class Moderation(commands.Cog): cursor.execute("SELECT timeout_duration FROM mod_config WHERE guild_id = ?", (interaction.guild_id,)) res = cursor.fetchone(); conn.close() await interaction.response.send_modal(ModConfigModal("Durée Timeout (s)", res[0])) + elif act == "appeal": + view = disnake.ui.View() + sel = disnake.ui.ChannelSelect(placeholder="Choisir salon...", channel_types=[disnake.ChannelType.text]) + async def sel_cb(i): + c = sel.values[0] + conn = sqlite3.connect(self.db_path); cursor = conn.cursor() + cursor.execute("UPDATE mod_config SET appeal_channel_id = ? WHERE guild_id = ?", (c.id, i.guild_id)) + conn.commit(); conn.close() + await i.response.send_message(f"✅ Salon de contestation mis à jour.", ephemeral=True) + await self.send_modconfig_v2(interaction, edit=True) + sel.callback = sel_cb; view.add_item(sel) + await interaction.response.send_message("Sélectionnez le salon pour la contestation :", view=view, ephemeral=True) async def warn(self, interaction, member, reason): conn = sqlite3.connect(self.db_path); cursor = conn.cursor() @@ -311,8 +408,9 @@ class Moderation(commands.Cog): if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} averti ({count}).", ephemeral=True) await self.send_mod_log(interaction.guild, [disnake.ui.Container(disnake.ui.Section(f"⚠️ Warn : {member}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)), disnake.ui.Separator(divider=True), disnake.ui.TextDisplay(f"Raison: {reason}"), disnake.ui.TextDisplay(f"Total: {count}"))]) await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="WARN", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id)) - try: await member.send(f"⚠️ Avertissement sur **{interaction.guild.name}**\nRaison: {reason}") - except: pass + dm_sent = await self.send_sanction_dm(member, "WARN", reason, interaction.user, interaction.guild) + if not dm_sent: + kuby_logger.warning(f"DM non envoyé à {member} (DMs peut-être bloqués)") if cfg: lt, lk, lb, dur = cfg if count >= lb: await member.ban(reason=f"Auto: {count} warns") @@ -329,22 +427,25 @@ class Moderation(commands.Cog): s_id, ts = cursor.fetchone(); conn.commit(); conn.close() if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} timeout.", ephemeral=True) await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="TIMEOUT", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id, duration=secs)) + await self.send_sanction_dm(member, "TIMEOUT", reason, interaction.user, interaction.guild, duration=secs) async def kick(self, interaction, member, reason): - await member.kick(reason=reason) conn = sqlite3.connect(self.db_path); cursor = conn.cursor() cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'KICK', reason)) cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "KICK" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id)) s_id, ts = cursor.fetchone(); conn.commit(); conn.close() + await self.send_sanction_dm(member, "KICK", reason, interaction.user, interaction.guild) + await member.kick(reason=reason) if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.name} expulsé.", ephemeral=True) await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="KICK", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id)) async def ban(self, interaction, user, reason): - await interaction.guild.ban(user, reason=reason) conn = sqlite3.connect(self.db_path); cursor = conn.cursor() cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, user.id, interaction.user.id, 'BAN', reason)) cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "BAN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, user.id)) s_id, ts = cursor.fetchone(); conn.commit(); conn.close() + await self.send_sanction_dm(user, "BAN", reason, interaction.user, interaction.guild) + await interaction.guild.ban(user, reason=reason) if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {user.name} banni.", ephemeral=True) await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="BAN", user=user, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id)) From b3db1ef9f5731b1da424e0f0b04afc85b9801df3 Mon Sep 17 00:00:00 2001 From: Lowei Date: Fri, 22 May 2026 14:26:02 +0200 Subject: [PATCH 13/23] =?UTF-8?q?feat(ticket):=20syst=C3=A8me=20de=20ticke?= =?UTF-8?q?t=20recrutement=20avec=20questions=20et=20DM=20accept/refus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout catégorie recrutement avec questions personnalisées - Modal dynamique affichant les questions lors de la création - Envoi des réponses dans un salon staff configurable - Fermeture avec boutons Accepter/Refuser et envoi DM automatique au candidat - Mise à jour des modèles TicketCategory et TicketData Co-Authored-By: Claude Opus 4.6 --- commandes/ticket/__init__.py | 445 ++++++++++++++++++++++++++++++-- commandes/ticket/data/models.py | 31 ++- 2 files changed, 451 insertions(+), 25 deletions(-) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index aecc64f..c95348e 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -542,9 +542,12 @@ class CategorySelectionView(disnake.ui.View): ephemeral=True ) return - - # Show priority modal - modal = TicketPriorityModal(self.bot, self.config, category) + + # Check if this is a recruitment category + if category.is_recruitment and category.questions: + modal = RecruitmentQuestionsModal(self.bot, self.config, category) + else: + modal = TicketPriorityModal(self.bot, self.config, category) await interaction.response.send_modal(modal) except Exception as e: kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True) @@ -593,7 +596,7 @@ class TicketPriorityModal(disnake.ui.Modal): async def on_error(self, interaction: disnake.Interaction, error: Exception): """Handle errors in the modal""" await interaction.response.send_message("Une erreur inattendue s'est produite lors de la création du ticket.", ephemeral=True) - + async def _create_ticket(self, interaction: disnake.Interaction, priority: Priority, reason: str): """Crée le ticket""" guild = interaction.guild @@ -702,6 +705,206 @@ class TicketPriorityModal(disnake.ui.Modal): await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True) +class RecruitmentQuestionsModal(disnake.ui.Modal): + """Modal pour les questions de recrutement""" + + def __init__(self, bot, config, category: TicketCategory): + self.bot = bot + self.config = config + self.category = category + + # Construire dynamiquement les inputs de questions + components = [] + self.question_inputs = {} + + for i, question in enumerate(category.questions): + input_field = disnake.ui.TextInput( + label=f"Question {i+1}", + custom_id=f"recruitment_q_{i}", + placeholder=question, + required=True, + max_length=1000, + style=disnake.TextInputStyle.paragraph + ) + self.question_inputs[f"recruitment_q_{i}"] = input_field + components.append(input_field) + + super().__init__(title=f"Recrutement: {category.name}", components=components) + + async def callback(self, interaction: disnake.ModalInteraction): + await interaction.response.defer(ephemeral=True) + try: + # Collecter les réponses + responses = {} + for key, input_field in self.question_inputs.items(): + responses[key] = interaction.text_values.get(key, "") + + # Créer le ticket avec les réponses stockées + await self._create_recruitment_ticket(interaction, responses) + except Exception as e: + await interaction.followup.send(f"Erreur lors de la création du ticket: {e}", ephemeral=True) + + async def _create_recruitment_ticket(self, interaction: disnake.Interaction, responses: Dict[str, str]): + """Crée le ticket de recrutement""" + guild = interaction.guild + user = interaction.user + + storage = get_storage() + + # Build permissions + overwrites = { + guild.default_role: disnake.PermissionOverwrite(read_messages=False), + user: disnake.PermissionOverwrite(read_messages=True, send_messages=True), + guild.me: disnake.PermissionOverwrite(read_messages=True, send_messages=True) + } + + # Add staff roles (category-specific roles override global ones) + staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles + for role_id in staff_roles_to_add: + try: + role = guild.get_role(int(role_id)) + if role and role not in overwrites: + overwrites[role] = disnake.PermissionOverwrite(read_messages=True, send_messages=True) + except (ValueError, TypeError): + continue + + # Create channel name + safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip() + if not safe_username: + safe_username = "user" + safe_category = "".join(c for c in self.category.name if not c.isdigit()).strip() + channel_name = f"recrutement-{safe_username}" + + try: + category_channel = None + if self.category.discord_category_id: + try: + category_channel = guild.get_channel(int(self.category.discord_category_id)) + if category_channel and not isinstance(category_channel, disnake.CategoryChannel): + category_channel = None + except (ValueError, TypeError): + pass + + if not category_channel and self.config.default_category: + try: + category_channel = guild.get_channel(int(self.config.default_category)) + if category_channel and not isinstance(category_channel, disnake.CategoryChannel): + category_channel = None + except (ValueError, TypeError): + pass + + ticket_channel = await guild.create_text_channel( + name=channel_name, + category=category_channel, + overwrites=overwrites, + topic=f"Recrutement de {user} | Catégorie: {self.category.name}" + ) + except Exception as e: + await interaction.followup.send(f"Erreur: {e}", ephemeral=True) + return + + # Create ticket data + ticket_data = TicketData( + channel_id=ticket_channel.id, + guild_id=guild.id, + user_id=user.id, + category_id=self.category.id, + priority=Priority.NORMAL, + status=TicketStatus.OPEN, + reason="Recrutement", + recruitment_responses=responses + ) + storage.save_ticket(ticket_data) + + # Build mentions for staff + mentions = [user.mention] + for role_id in staff_roles_to_add: + mentions.append(f"<@&{role_id}>") + + mention_content = " ".join(mentions) + + # Send welcome message in ticket channel + welcome_text = f"Bienvenue {user.mention} !\n\nMerci de votre intérêt pour rejoindre notre équipe.\n\n" + welcome_text += "Nos responsables vont analyser vos réponses et vous contacteront.\n\n" + + # Afficher les questions et réponses + welcome_text += "**Vos réponses:**\n" + for i, question in enumerate(self.category.questions): + key = f"recruitment_q_{i}" + response = responses.get(key, "Non répondu") + welcome_text += f"> **{question}**\n> {response}\n\n" + + # Create container with management buttons and recruitment responses + sections = [ + disnake.ui.TextDisplay(content=welcome_text), + disnake.ui.Separator(divider=True) + ] + + # Add management buttons if staff + sections.append( + disnake.ui.Section( + "Actions", + accessory=disnake.ui.Button( + label="🔒 Fermer (Accepté/Refusé)", + style=disnake.ButtonStyle.green, + custom_id="recruitment_close" + ) + ) + ) + + container = disnake.ui.Container(*sections) + await ticket_channel.send(components=[disnake.ui.Container( + disnake.ui.TextDisplay(content=mention_content), + disnake.ui.Separator(divider=True), + *sections + )]) + + # Envoyer les réponses dans le salon staff si configuré + if self.category.responses_channel_id: + try: + responses_channel = guild.get_channel(self.category.responses_channel_id) + if responses_channel: + embed = disnake.Embed( + title=f"📝 Nouveau Candidature - {user}", + description=f"**Candidature dans** {ticket_channel.mention}", + color=disnake.Color.blue(), + timestamp=datetime.now() + ) + embed.set_thumbnail(url=user.display_avatar.url) + + for i, question in enumerate(self.category.questions): + key = f"recruitment_q_{i}" + response = responses.get(key, "Non répondu") + embed.add_field( + name=f"Q{i+1}: {question}", + value=response, + inline=False + ) + + embed.add_field(name="Candidat", value=user.mention, inline=True) + embed.add_field(name="Discord ID", value=str(user.id), inline=True) + + await responses_channel.send(embed=embed) + except Exception as e: + print(f"Error sending recruitment responses to staff channel: {e}") + + # Log + if self.config.log_channel_id: + log_channel = guild.get_channel(self.config.log_channel_id) + if log_channel: + log_embed = disnake.Embed( + title="🎓 Nouveau Ticket de Recrutement", + color=disnake.Color.purple(), + timestamp=datetime.now() + ) + log_embed.add_field(name="Utilisateur", value=user.mention, inline=True) + log_embed.add_field(name="Catégorie", value=self.category.name, inline=True) + log_embed.add_field(name="Salon", value=ticket_channel.mention, inline=True) + await log_channel.send(embed=log_embed) + + await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True) + + class SetCategoryModal(disnake.ui.Modal): """Modal pour définir la catégorie Discord où les tickets seront créés""" @@ -2101,31 +2304,84 @@ class AddCategoryModal(disnake.ui.Modal): self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True) self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph) self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5) - super().__init__(title="Ajouter une Catégorie", components=[self.name_input, self.desc_input, self.emoji_input]) + # Recruitment fields + self.is_recruitment_input = disnake.ui.TextInput( + label="Recrutement? (oui/non)", + custom_id="add_cat_recruitment", + placeholder="non", + required=False, + max_length=5 + ) + self.questions_input = disnake.ui.TextInput( + label="Questions recrutement (une par ligne)", + custom_id="add_cat_questions", + placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?", + required=False, + style=disnake.TextInputStyle.paragraph + ) + self.responses_channel_input = disnake.ui.TextInput( + label="ID salon pour les réponses", + custom_id="add_cat_responses_channel", + placeholder="ID du salon staff où envoyer les réponses", + required=False, + max_length=20 + ) + super().__init__( + title="Ajouter une Catégorie", + components=[ + self.name_input, + self.desc_input, + self.emoji_input, + self.is_recruitment_input, + self.questions_input, + self.responses_channel_input + ] + ) self.bot = bot self.storage = storage self.config = config - + async def callback(self, interaction: disnake.ModalInteraction): import uuid - + cat_id = str(uuid.uuid4())[:8] - + name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie") desc_val = interaction.text_values.get("add_cat_desc", "") emoji_val = interaction.text_values.get("add_cat_emoji", "🎫") - + is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non") + questions_val = interaction.text_values.get("add_cat_questions", "") + responses_channel_val = interaction.text_values.get("add_cat_responses_channel", "") + + is_recruitment = is_recruitment_val.lower() in ["oui", "yes", "o", "y", "1", "true"] + questions = [q.strip() for q in questions_val.split("\n") if q.strip()] if questions_val else [] + responses_channel_id = None + if responses_channel_val and responses_channel_val.isdigit(): + responses_channel_id = int(responses_channel_val) + category = TicketCategory( id=cat_id, name=name_val, description=desc_val, - emoji=emoji_val + emoji=emoji_val, + is_recruitment=is_recruitment, + questions=questions, + responses_channel_id=responses_channel_id ) - + self.config.categories.append(category) self.storage.save_config(interaction.guild_id, self.config) - - await interaction.response.send_message(f"Catégorie **{name_val}** créée!", ephemeral=True) + + recruitment_info = "" + if is_recruitment: + recruitment_info = f"\n✅ Mode recrutement activé avec {len(questions)} question(s)" + if responses_channel_id: + recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>" + + await interaction.response.send_message( + f"Catégorie **{name_val}** créée!{recruitment_info}", + ephemeral=True + ) class EditCategoryModal(disnake.ui.Modal): @@ -2851,7 +3107,7 @@ class TicketCommands(commands.Cog): if not ( str(custom_id).startswith("ticket_") or str(custom_id).startswith("cat_") - or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"} + or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel", "recruitment_close", "recruitment_accept", "recruitment_refuse"} ): return @@ -2900,8 +3156,11 @@ class TicketCommands(commands.Cog): await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) return - # ouvrir directement le modal de priorité (même logique que CategorySelectionView) - modal = TicketPriorityModal(self.bot, config, category) + # Check if recruitment category with questions + if category.is_recruitment and category.questions: + modal = RecruitmentQuestionsModal(self.bot, config, category) + else: + modal = TicketPriorityModal(self.bot, config, category) await inter.response.send_modal(modal) except Exception as e: kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) @@ -2934,6 +3193,80 @@ class TicketCommands(commands.Cog): view = TicketManagementView(self.bot, self.storage, config, ticket) await view._transcript_callback(inter) + # === Recrutement: Fermeture avec Accepté/Refusé === + elif custom_id == "recruitment_close": + ticket = self.storage.load_ticket(inter.channel.id) + if not ticket: + await inter.response.send_message("Ticket non trouvé.", ephemeral=True) + return + + category = config.get_category(ticket.category_id) + if not category or not category.is_recruitment: + # Si pas un ticket recrutement, utiliser la fermeture normale + view = TicketManagementView(self.bot, self.storage, config, ticket) + await view._close_callback(inter) + return + + # Vérifier que c'est du staff + if not is_staff(inter, config, category): + await inter.response.send_message("Réservé au staff.", ephemeral=True) + return + + await inter.response.send_message( + "Choisissez la décision pour cette candidature:", + components=[ + disnake.ui.Container( + disnake.ui.Section( + "# 🎓 Décision de Recrutement", + accessory=disnake.ui.Button( + label="✅ Accepter", + style=disnake.ButtonStyle.green, + custom_id="recruitment_accept" + ) + ), + disnake.ui.Separator(divider=True), + disnake.ui.Section( + "Refuser la candidature", + accessory=disnake.ui.Button( + label="❌ Refuser", + style=disnake.ButtonStyle.red, + custom_id="recruitment_refuse" + ) + ) + ) + ], + flags=disnake.MessageFlags(is_components_v2=True), + ephemeral=True + ) + + elif custom_id == "recruitment_accept": + ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None + if not ticket: + await inter.response.send_message("Ticket non trouvé.", ephemeral=True) + return + + category = config.get_category(ticket.category_id) + if not is_staff(inter, config, category): + await inter.response.send_message("Réservé au staff.", ephemeral=True) + return + + await inter.response.defer() + await self._send_recruitment_decision(inter, ticket, category, accepted=True) + + elif custom_id == "recruitment_refuse": + ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None + if not ticket: + await inter.response.send_message("Ticket non trouvé.", ephemeral=True) + return + + category = config.get_category(ticket.category_id) + if not is_staff(inter, config, category): + await inter.response.send_message("Réservé au staff.", ephemeral=True) + return + + await inter.response.defer() + await self._send_recruitment_decision(inter, ticket, category, accepted=False) + # === Fermeture via composants V2 (sans view=, donc custom_id) === elif custom_id == "close_cancel": await inter.response.defer() @@ -3259,7 +3592,85 @@ class TicketCommands(commands.Cog): print(f"[Ticket] Error registering views for guild {guild_id}: {e}") except Exception as e: print(f"[Ticket] Error in _register_all_views: {e}") - + + async def _send_recruitment_decision(self, inter: disnake.Interaction, ticket, category, accepted: bool): + """Envoie la décision de recrutement (Accepté/Refusé) au candidat via DM""" + try: + guild = inter.guild + user = guild.get_member(ticket.user_id) + if not user: + try: + user = await guild.fetch_member(ticket.user_id) + except Exception: + pass + + # Déterminer le message selon la décision + if accepted: + title = "✅ Candidature Acceptée !" + color = disnake.Color.green() + description = ( + f"Félicitations {user.mention} !\n\n" + f"Votre candidature pour **Boga Life** a été acceptée.\n\n" + f"Un responsable va bientôt vous contacter pour les prochaines étapes.\n\n" + f"Bienvenue dans l'équipe ! 🎉" + ) + else: + title = "❌ Candidature Refusée" + color = disnake.Color.red() + description = ( + f"Bonjour {user.mention},\n\n" + f"Après analyse de votre candidature, nous avons le regret de vous informer " + f"que celle-ci n'a pas été retenue pour le poste.\n\n" + f"Cette décision ne remet pas en cause vos qualités. N'hésitez pas à repostuler " + f"ultérieurement si vous êtes toujours intéressé(e).\n\n" + f"Nous vous souhaitons bonne continuation." + ) + + embed = disnake.Embed( + title=title, + description=description, + color=color, + timestamp=datetime.now() + ) + embed.set_footer(text="Boga Life - Système de Recrutement") + + # Envoyer le DM à l'utilisateur + if user: + try: + await user.send(embed=embed) + await inter.followup.send("✅ Décision envoyée au candidat par DM.", ephemeral=True) + except disnake.Forbidden: + await inter.followup.send("⚠️ Impossible d'envoyer un DM - l'utilisateur a ses messages privés fermés.", ephemeral=True) + except Exception as e: + await inter.followup.send(f"⚠️ Erreur lors de l'envoi du DM: {e}", ephemeral=True) + else: + await inter.followup.send("⚠️ Utilisateur introuvable.", ephemeral=True) + + # Fermer le ticket + ticket.status = TicketStatus.CLOSED + ticket.closed_at = datetime.now() + self.storage.save_ticket(ticket) + + # Envoyer un message dans le salon du ticket + close_embed = disnake.Embed( + title=f"🔒 Ticket Fermé - {'Accepté' if accepted else 'Refusé'}", + description=f"Candidature traitée par {inter.user.mention}", + color=color + ) + close_embed.add_field(name="Decision", value="✅ Acceptee" if accepted else "❌ Refusee", inline=True) + + # Supprimer le salon + try: + await inter.channel.delete(reason=f"Recrutement termine - {'Accepte' if accepted else 'Refuse'} par {inter.user}") + except Exception as e: + await inter.channel.send(embed=close_embed) + print(f"Error deleting recruitment channel: {e}") + + except Exception as e: + kuby_logger.error(f"[TicketSystem] _send_recruitment_decision failed: {e}", exc_info=True) + if not inter.response.is_done(): + await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True) + # === COMMANDES === @commands.slash_command(name="ticket", description="Ouvre le panel des tickets") diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index d03ce03..f1fd33a 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -60,7 +60,10 @@ class TicketCategory: priority_enabled: bool = True, allow_claims: bool = True, survey_enabled: bool = True, - discord_category_id: Optional[int] = None + discord_category_id: Optional[int] = None, + is_recruitment: bool = False, + questions: List[str] = None, + responses_channel_id: Optional[int] = None ): self.id = id self.name = name @@ -76,7 +79,10 @@ class TicketCategory: self.allow_claims = allow_claims self.survey_enabled = survey_enabled self.discord_category_id = discord_category_id - + self.is_recruitment = is_recruitment + self.questions = questions or [] + self.responses_channel_id = responses_channel_id + def to_dict(self) -> Dict[str, Any]: """Convert category to dictionary for storage""" return { @@ -93,7 +99,10 @@ class TicketCategory: "priority_enabled": self.priority_enabled, "allow_claims": self.allow_claims, "survey_enabled": self.survey_enabled, - "discord_category_id": self.discord_category_id + "discord_category_id": self.discord_category_id, + "is_recruitment": self.is_recruitment, + "questions": self.questions, + "responses_channel_id": self.responses_channel_id } @classmethod @@ -113,7 +122,10 @@ class TicketCategory: priority_enabled=data.get("priority_enabled", True), allow_claims=data.get("allow_claims", True), survey_enabled=data.get("survey_enabled", True), - discord_category_id=data.get("discord_category_id") + discord_category_id=data.get("discord_category_id"), + is_recruitment=data.get("is_recruitment", False), + questions=data.get("questions", []), + responses_channel_id=data.get("responses_channel_id") ) @@ -188,7 +200,8 @@ class TicketData: transcript_path: Optional[str] = None rating: Optional[int] = None feedback: Optional[str] = None - + recruitment_responses: Dict[str, str] = field(default_factory=dict) + @property def is_open(self) -> bool: return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED] @@ -214,9 +227,10 @@ class TicketData: "reason": self.reason, "transcript_path": self.transcript_path, "rating": self.rating, - "feedback": self.feedback + "feedback": self.feedback, + "recruitment_responses": self.recruitment_responses } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'TicketData': messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])] @@ -234,7 +248,8 @@ class TicketData: reason=data.get("reason", ""), transcript_path=data.get("transcript_path"), rating=data.get("rating"), - feedback=data.get("feedback") + feedback=data.get("feedback"), + recruitment_responses=data.get("recruitment_responses", {}) ) From f570ac4fcee1b92aabb1128ed52cd20363ce2831 Mon Sep 17 00:00:00 2001 From: Lowei Date: Fri, 22 May 2026 18:25:55 +0200 Subject: [PATCH 14/23] fix(logger): handle empty/corrupt member log files gracefully Return None instead of crashing when check_previous_roles finds no parsable JSON lines in a member's log file. Co-Authored-By: Claude Opus 4.6 --- src/advanced_logger.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/advanced_logger.py b/src/advanced_logger.py index 72b25dd..f4cbfd5 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -107,17 +107,17 @@ class AdvancedLogger: def check_previous_roles(self, member_id: int) -> Optional[Dict]: """Check if member has previous role data when rejoining""" member_log_file = f"{self.member_logs_dir}/{member_id}.json" - + if not os.path.exists(member_log_file): return None - + try: logs = [] with open(member_log_file, 'r', encoding='utf-8') as f: content = f.read().strip() if not content: return None - + # Handle legacy JSON list format if content.startswith('['): try: @@ -127,7 +127,7 @@ class AdvancedLogger: content_clean = content.strip('[]').strip() # This is getting complex, let's just try line-by-line fallback pass - + # If not a list or failed list parse, try NDJSON line-by-line if not logs: for line in content.splitlines(): @@ -139,7 +139,11 @@ class AdvancedLogger: logs.append(json.loads(line)) except json.JSONDecodeError: continue # Skip corrupt lines - + + # Guard against empty logs list after parsing + if not logs: + return None + # Find the most recent leave event leave_events = [log for log in logs if isinstance(log, dict) and log.get("event_type") == "member_leave"] if leave_events: From 6621e87b56227211ca6b52cba1e05a174c5ca550 Mon Sep 17 00:00:00 2001 From: Lowei Date: Fri, 22 May 2026 18:29:56 +0200 Subject: [PATCH 15/23] =?UTF-8?q?fix(ticket):=20=C3=A9viter=20le=20double?= =?UTF-8?q?=20envoi=20de=20r=C3=A9ponse=20sur=20les=20boutons=20primary=20?= =?UTF-8?q?panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les boutons ticket_create, ticket_my_tickets et ticket_analytics avaient leur callback assigné directement dans TicketPanelView ET étaient aussi routés via on_interaction. Cela provoquait une double réponse d'interaction → erreur 40060 "Interaction has already been acknowledged". Suppression des assignations de callback directes pour ces trois boutons. Co-Authored-By: Claude Opus 4.6 --- commandes/ticket/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index c95348e..b71b953 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -76,7 +76,6 @@ class TicketPanelView(disnake.ui.View): label="🎫 Créer un ticket", custom_id="ticket_create" ) - create_btn.callback = self._create_callback self.add_item(create_btn) # Mes tickets (si l'utilisateur a des tickets) @@ -85,7 +84,6 @@ class TicketPanelView(disnake.ui.View): label="📂 Mes tickets", custom_id="ticket_my_tickets" ) - my_tickets_btn.callback = self._my_tickets_callback self.add_item(my_tickets_btn) # Analytics (staff only) @@ -94,7 +92,6 @@ class TicketPanelView(disnake.ui.View): label="📊 Analytics", custom_id="ticket_analytics" ) - analytics_btn.callback = self._analytics_callback self.add_item(analytics_btn) # Staff Ratings (staff only) From 3534363f98467b45bbbe4df9a0d39c25d08181ac Mon Sep 17 00:00:00 2001 From: Lowei Date: Fri, 22 May 2026 18:46:32 +0200 Subject: [PATCH 16/23] =?UTF-8?q?fix(bot):=20am=C3=A9liorer=20la=20robuste?= =?UTF-8?q?sse=20des=20handlers=20d'erreurs=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - advanced_logger.py: parsing NDJSON plus robuste pour les fichiers JSON corrompus ou malformés lors de la vérification des rôles précédents. Évite l'erreur "Expecting value: line 2 column 1". - bot.py: simplification de la détection d'erreur 403 Missing Permissions (codes 50013 et 20023) lors de la restauration des rôles au join d'un membre. Évite le log d'erreur "Failed to restore safe roles" quand c'est juste un problème de permissions/bot role hierarchy. - bug_report.py: simplification de la détection du code 50007 (Cannot send messages to this user) lors de l'envoi de notifications de labels GitLab. Évite le log "Erreur inattendue dans la notification de label". Co-Authored-By: Claude Opus 4.6 --- bot.py | 7 +++++++ commandes/bug_report.py | 7 +++---- src/advanced_logger.py | 31 +++++++++++++++++-------------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/bot.py b/bot.py index 6883ee5..eca411f 100755 --- a/bot.py +++ b/bot.py @@ -189,6 +189,13 @@ class MyBot(commands.Bot): is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504] if not isinstance(e, disnake.HTTPException): is_transient = True + # 403 with code 50013 = Missing Permissions - don't retry + if isinstance(e, disnake.HTTPException) and e.status == 403: + error_code = getattr(e, 'code', None) + # 50013 = Missing Permissions, 20023 = Cannot modify a role higher than bot's highest role + if error_code in (50013, 20023): + kuby_logger.warning(f"Missing Manage Roles permission to restore safe roles for {member} in {member.guild.name} - please ensure the bot has the Manage Roles permission") + break if is_transient and i < retries - 1: await asyncio.sleep((i + 1) * 2) continue diff --git a/commandes/bug_report.py b/commandes/bug_report.py index bab31e3..e319851 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -323,11 +323,10 @@ class BugReport(commands.Cog): f"**{current_labels_str}** pour l'issue #{issue_iid} ({issue_title})." ) except (disnake.Forbidden, disnake.HTTPException) as e: - if isinstance(e, disnake.Forbidden) or ( - isinstance(e, disnake.HTTPException) and e.code == 50007 - ): + error_code = getattr(e, 'code', None) + if isinstance(e, disnake.Forbidden) or error_code == 50007: kuby_logger.warning( - f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés (Code: {getattr(e, 'code', 'Unknown')})" + f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés (Code: {error_code})" ) if dev and dev.id != user.id: await dev.send( diff --git a/src/advanced_logger.py b/src/advanced_logger.py index f4cbfd5..9c65f39 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -123,22 +123,25 @@ class AdvancedLogger: try: logs = json.loads(content) except json.JSONDecodeError: - # Fallback: try to strip brackets and commas - content_clean = content.strip('[]').strip() - # This is getting complex, let's just try line-by-line fallback + # File is corrupt or semi-corrupt, try line-by-line pass - # If not a list or failed list parse, try NDJSON line-by-line - if not logs: - for line in content.splitlines(): - line = line.strip() - if not line: continue - # Strip trailing/leading commas in case of semi-corrupt legacy conversion - line = line.strip(',') - try: - logs.append(json.loads(line)) - except json.JSONDecodeError: - continue # Skip corrupt lines + # Try NDJSON line-by-line (handles both legacy and current format) + lines = content.splitlines() + for line in lines: + line = line.strip() + if not line: + continue + # Strip trailing/leading commas in case of semi-corrupt lines + line = line.strip(',') + if not line: + continue + try: + parsed = json.loads(line) + if isinstance(parsed, dict): + logs.append(parsed) + except json.JSONDecodeError: + continue # Skip corrupt lines # Guard against empty logs list after parsing if not logs: From 19fa4232100a5306ab460fbdb7e6353bca02d718 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 10:58:21 +0200 Subject: [PATCH 17/23] =?UTF-8?q?Fix:=20mise=20=C3=A0=20jour=20des=20cat?= =?UTF-8?q?=C3=A9gories=20dans=20le=20panel=20/ticket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 11 --------- commandes/ticket/__init__.py | 43 +++++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 18cf720..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +0,0 @@ -# TODO - Kuby - -- [x] Identifier le cog qui démarre le serveur aiohttp sur 127.0.0.1:5001 (bug_report.py) -- [x] Remplacer l’async hook `cog_load` par un démarrage différé via `__init__` + `bot.loop.create_task` (disnake 2.12.0) -- [x] Renommer la logique serveur en `_start_webhook_server` -- [x] Attendre `await bot.wait_until_ready()` avant d’ouvrir le port -- [x] Conserver le retry bind port 5001 (OSError errno 98) -- [ ] Redémarrer le bot -- [ ] Vérifier dans les logs: "Internal Bot Webhook Server started on 127.0.0.1:5001" - - diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index b71b953..eaa4c41 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -182,7 +182,7 @@ class TicketPanelView(disnake.ui.View): accessory=disnake.ui.Button( label=cat.name, style=disnake.ButtonStyle.primary, - custom_id=f"cat_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}" +custom_id=f"cat|{cat.id}|{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}" ) ) ) @@ -3138,17 +3138,17 @@ class TicketCommands(commands.Cog): view = TicketPanelView(self.bot, config, self.storage) await view._analytics_callback(inter) - # ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = cat_{cat.id}_{snowflake} - elif custom_id.startswith("cat_"): + # ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = cat|| + elif str(custom_id).startswith("cat|"): try: - # format: cat__ - parts = custom_id.split("_") - if len(parts) >= 3: - cat_id = "_".join(parts[1:-1]) - else: - cat_id = parts[1] if len(parts) > 1 else None + # format: cat|| + parts = str(custom_id).split("|") + if len(parts) < 3: + await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) + return - category = next((c for c in config.categories if c.id == cat_id), None) + cat_id = parts[1] + category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) if not category: await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) return @@ -3158,6 +3158,29 @@ class TicketCommands(commands.Cog): modal = RecruitmentQuestionsModal(self.bot, config, category) else: modal = TicketPriorityModal(self.bot, config, category) + + await inter.response.send_modal(modal) + except Exception as e: + kuby_logger.error(f"[TicketSystem] cat| routing error: {e}", exc_info=True) + if not inter.response.is_done(): + await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) + + # backward compatibility (legacy cat__) + elif custom_id.startswith("cat_"): + try: + parts = str(custom_id).split("_") + cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None) + + category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) + if not category: + await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) + return + + if category.is_recruitment and category.questions: + modal = RecruitmentQuestionsModal(self.bot, config, category) + else: + modal = TicketPriorityModal(self.bot, config, category) + await inter.response.send_modal(modal) except Exception as e: kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) From e11f0d8b4aefcf2543a059c461c3632220eea169 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 12:48:08 +0200 Subject: [PATCH 18/23] =?UTF-8?q?fix(ticket):=20isoler=20les=20permissions?= =?UTF-8?q?=20staff=20par=20cat=C3=A9gorie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bug faisait que les rôles staff ajoutés via le panneau d'une catégorie spécifique étaient incorrectement stockés dans config_roles au lieu de staff_roles de la catégorie. Corrige PermissionConfigView pour gérer correctement config_type="cat_{id}". Co-Authored-By: Claude Opus 4.6 --- commandes/ticket/__init__.py | 97 +++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index eaa4c41..852db08 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -2899,18 +2899,27 @@ class PermissionConfigView(disnake.ui.View): # Determine which list to update if self.config_type == "staff": target_list = self.config.staff_roles - else: + elif self.config_type == "config": target_list = self.config.config_roles - + else: + # Category-specific staff roles (config_type = "cat_{cat_id}") + cat_id = self.config_type.replace("cat_", "") + category = self.config.get_category(cat_id) + if category: + target_list = category.staff_roles + else: + await interaction.response.send_message("Catégorie introuvable.", ephemeral=True) + return + # Get new selected IDs new_ids = interaction.data.get('values', []) added_count = 0 - + for new_id in new_ids: if new_id not in target_list: target_list.append(new_id) added_count += 1 - + self.storage.save_config(interaction.guild_id, self.config) @@ -2927,17 +2936,20 @@ class PermissionConfigView(disnake.ui.View): try: if self.config_type == "staff": target_list = self.config.staff_users - else: + elif self.config_type == "config": target_list = self.config.config_users - + else: + # Category-specific staff - users not separately managed per category, use global staff_users + target_list = self.config.staff_users + new_ids = [int(uid) for uid in interaction.data.get('values', [])] added_count = 0 - + for new_id in new_ids: if new_id not in target_list: target_list.append(new_id) added_count += 1 - + self.storage.save_config(interaction.guild_id, self.config) @@ -2958,9 +2970,20 @@ class PermissionConfigView(disnake.ui.View): await interaction.response.edit_message(components=self.get_components_v2()) def get_components_v2(self): - title = "🛡️ Permissions Staff" if self.config_type == "staff" else "⚙️ Permissions Config" - desc = "Configurez qui peut gérer les tickets." if self.config_type == "staff" else "Configurez qui peut modifier les paramètres." - + if self.config_type == "staff": + title = "🛡️ Permissions Staff" + desc = "Configurez qui peut gérer les tickets." + elif self.config_type == "config": + title = "⚙️ Permissions Config" + desc = "Configurez qui peut modifier les paramètres." + else: + # Category-specific staff roles + cat_id = self.config_type.replace("cat_", "") + category = self.config.get_category(cat_id) + cat_name = category.name if category else f"Catégorie {cat_id}" + title = f"🛡️ Permissions Staff - {cat_name}" + desc = f"Configurez les rôles staff ayant accès à la catégorie '{cat_name}'." + sections = [ disnake.ui.TextDisplay(content=f"# {title}\n{desc}"), disnake.ui.Separator(divider=True), @@ -2975,17 +2998,26 @@ class PermissionConfigView(disnake.ui.View): def _add_remove_select(self): # Build options from current config options = [] - + if self.config_type == "staff": roles = self.config.staff_roles users = self.config.staff_users - else: + elif self.config_type == "config": roles = self.config.config_roles users = self.config.config_users - + else: + # Category-specific staff roles + cat_id = self.config_type.replace("cat_", "") + category = self.config.get_category(cat_id) + if category: + roles = category.staff_roles + else: + roles = [] + users = self.config.staff_users # Category uses global staff_users + # Limit to 25 items for removal dropdown (API Limit) # If more, we might need pagination or just show first 25. - + count = 0 for r_id in roles: if count >= 25: break @@ -2998,7 +3030,7 @@ class PermissionConfigView(disnake.ui.View): description="Cliquez pour retirer" )) count += 1 - + for u_id in users: if count >= 25: break user = self.guild.get_member(u_id) @@ -3010,7 +3042,7 @@ class PermissionConfigView(disnake.ui.View): description="Cliquez pour retirer" )) count += 1 - + if options: select = disnake.ui.Select( placeholder="Sélectionnez pour RETIRER des permissions...", @@ -3026,23 +3058,40 @@ class PermissionConfigView(disnake.ui.View): async def _remove_callback(self, interaction: disnake.Interaction): try: values = interaction.data.get('values', []) - + removed_count = 0 for val in values: prefix, id_str = val.split('_') id_val = int(id_str) - - if prefix == 'r': # Role - target_list = self.config.staff_roles if self.config_type == "staff" else self.config.config_roles + + if prefix == 'r': # Role + if self.config_type == "staff": + target_list = self.config.staff_roles + elif self.config_type == "config": + target_list = self.config.config_roles + else: + # Category-specific staff roles + cat_id = self.config_type.replace("cat_", "") + category = self.config.get_category(cat_id) + if category: + target_list = category.staff_roles + else: + continue if str(id_val) in target_list: target_list.remove(str(id_val)) removed_count += 1 - elif prefix == 'u': # User - target_list = self.config.staff_users if self.config_type == "staff" else self.config.config_users + elif prefix == 'u': # User + if self.config_type == "staff": + target_list = self.config.staff_users + elif self.config_type == "config": + target_list = self.config.config_users + else: + # Category-specific staff uses global staff_users + target_list = self.config.staff_users if id_val in target_list: target_list.remove(id_val) removed_count += 1 - + self.storage.save_config(interaction.guild_id, self.config) From fac7ed1e6b1b500f54d8dfceb4f593c1b4260b42 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 13:15:53 +0200 Subject: [PATCH 19/23] =?UTF-8?q?fix(rules):=20am=C3=A9liorer=20le=20syst?= =?UTF-8?q?=C3=A8me=20de=20r=C3=A8glement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rendu markdown via Container avec TextDisplay au lieu de embed.description - Commandes bug/suggestion utilisables avec arguments directs (copy-paste supporté) - Échappement des mentions @everyone/@here pour éviter notifications involontaires Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 75 +++++++++++++++++++++++++++-- commandes/modules_security/rules.py | 29 +++++++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index e319851..05e1fcb 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -447,11 +447,14 @@ class BugReport(commands.Cog): async def signaler_bug( self, interaction: disnake.ApplicationCommandInteraction, + titre: str = commands.Param(description="Titre/Courte description du bug"), + description: str = commands.Param(description="Description détaillée (copy-paste accepté !)"), priority: str = commands.Param( description="Priorité du bug", choices={"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"}, ), ): + """Signale un bug directement avec arguments - copy-paste supporté.""" from collections import namedtuple Choice = namedtuple('Choice', ['name', 'value']) @@ -461,14 +464,80 @@ class BugReport(commands.Cog): p_name = reverse.get(priority, "Normal") choice = Choice(name=p_name, value=priority) - await interaction.response.send_modal(BugReportModal(choice)) + await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True) + + bug_title = titre.strip() if titre else "Sans titre" + priority_name = choice.name + description_value = description + + description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n" + description_text += f"**Priorité:** {priority_name}\n\n" + description_text += f"**Description:**\n{description_value}" + + labels = ["bug", "manual-report", f"Priority::{choice.value}"] + + result = await gitlab_client.create_issue( + title=f"[MANUAL] {bug_title}", + description=description_text, + labels=labels, + ) + + if result: + issue_iid = result.get("iid") + save_report(issue_iid, interaction.user.id) + await interaction.edit_original_response( + content=( + "✅ Votre bug a été signalé avec succès ! " + "Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. " + f"[Voir l'issue]({result.get('web_url')})" + ) + ) + else: + await interaction.edit_original_response( + content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur." + ) @commands.slash_command( name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité pour le bot", ) - async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): - await interaction.response.send_modal(FeatureSuggestionModal()) + async def suggerer_fonctionnalite( + self, + interaction: disnake.ApplicationCommandInteraction, + titre: str = commands.Param(description="Titre de la suggestion"), + description: str = commands.Param(description="Détails de la fonctionnalité (copy-paste accepté !)"), + ): + """Suggère une fonctionnalité directement avec arguments - copy-paste supporté.""" + await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True) + + suggestion_title = titre.strip() if titre else "Sans titre" + description_value = description + + description_text = f"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n" + description_text += f"**Description:**\n{description_value}" + + labels = ["enhancement", "manual-suggestion"] + + result = await gitlab_client.create_issue( + title=f"[SUGGESTION] {suggestion_title}", + description=description_text, + labels=labels, + ) + + if result: + issue_iid = result.get("iid") + save_report(issue_iid, interaction.user.id) + await interaction.edit_original_response( + content=( + "✅ Votre suggestion a été envoyée avec succès ! " + "Merci de contribuer à l'amélioration du bot. " + f"[Voir l'issue]({result.get('web_url')})" + ) + ) + else: + await interaction.edit_original_response( + content="❌ Une erreur est survenue lors de l'envoi de la suggestion à GitLab." + ) def setup(bot): diff --git a/commandes/modules_security/rules.py b/commandes/modules_security/rules.py index 2373eb8..05f0766 100644 --- a/commandes/modules_security/rules.py +++ b/commandes/modules_security/rules.py @@ -8,6 +8,14 @@ from typing import Optional SETTINGS_FILE = "data/security_settings.json" +def escape_mentions(text: str) -> str: + """Échappe les mentions @everyone et @here pour éviter les notifications involontaires""" + if not text: + return text + # Utilise un zero-width space pour briser la mention Discord + return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") + + def load_settings(guild_id: int) -> dict: """Charge les paramètres de sécurité pour un serveur""" if not os.path.exists(SETTINGS_FILE): @@ -95,7 +103,7 @@ class RulesContentModal(disnake.ui.Modal): self.on_update_callback = on_update_callback async def callback(self, interaction: disnake.Interaction): - self.settings["rules_content"] = self.content_input.value + self.settings["rules_content"] = escape_mentions(self.content_input.value) save_settings(self.guild_id, self.settings) if self.on_update_callback: @@ -434,10 +442,11 @@ class RulesCog(commands.Cog): view = RulesAcceptButton(channel.guild.id, accept_role_id) if message_type == "simple": - # Message simple + # Message simple - échappe les mentions @everyone/@here pour éviter les notifications involontaires + safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") text_message = ( f"📜 **{title}**\n\n" - f"{content}\n\n" + f"{safe_content}\n\n" "Bienvenue ! Pour accéder à l'ensemble des canaux du serveur, " "vous devez accepter le règlement en cliquant sur le bouton ci-dessous.\n\n" "En acceptant, vous confirmez avoir lu et compris les règles du serveur." @@ -455,19 +464,29 @@ class RulesCog(commands.Cog): return None else: # embed + # Échappe les mentions @everyone/@here pour éviter les notifications involontaires + safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") + + # Utilise un Container avec TextDisplay pour un meilleur rendu du texte (markdown supporté) + components = [ + disnake.ui.Container( + disnake.ui.TextDisplay(safe_content) + ) + ] + # Créer l'embed embed = disnake.Embed( title=title, - description=content, color=0x2b2d31 ) embed.set_footer(text="Cliquez sur le bouton ci-dessous pour accepter le règlement") - # Envoyer seulement l'embed + # Envoyer l'embed avec le container et la view try: message = await channel.send( embed=embed, + components=components, view=view ) return message From 8da858d1d8faa6555de63f76652e690571bbfe2e Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 15:11:47 +0200 Subject: [PATCH 20/23] feat(bug_report): convertir les commandes en formulaires modaux Les commandes /signaler_bug et /suggerer_fonctionnalite ouvrent maintenant un formulaire modal au lieu d'afficher des options slash. Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 114 +++++----------------------------------- 1 file changed, 12 insertions(+), 102 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 05e1fcb..13292a8 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -35,16 +35,7 @@ def save_report(issue_iid, user_id): class BugReportModal(disnake.ui.Modal): - def __init__(self, priority_choice): - if priority_choice is None: - self.priority_choice = type( - "PriorityChoice", - (), - {"name": "Normal", "value": "Normal"}, - )() - else: - self.priority_choice = priority_choice - + def __init__(self): super().__init__( title="Signaler un Bug", components=[ @@ -62,7 +53,7 @@ class BugReportModal(disnake.ui.Modal): custom_id="bug_description", required=True, max_length=1000, - ) + ), ] ) @@ -73,8 +64,8 @@ class BugReportModal(disnake.ui.Modal): if not bug_title: bug_title = "Sans titre" - priority_name = getattr(self.priority_choice, "name", None) or "Normal" - priority_value = getattr(self.priority_choice, "value", None) or "Normal" + priority_name = "Normale" + priority_value = "Normal" description_value = interaction.text_values.get("bug_description") or "" @@ -444,100 +435,19 @@ class BugReport(commands.Cog): name="signaler_bug", description="Signaler un bug aux développeurs", ) - async def signaler_bug( - self, - interaction: disnake.ApplicationCommandInteraction, - titre: str = commands.Param(description="Titre/Courte description du bug"), - description: str = commands.Param(description="Description détaillée (copy-paste accepté !)"), - priority: str = commands.Param( - description="Priorité du bug", - choices={"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"}, - ), - ): - """Signale un bug directement avec arguments - copy-paste supporté.""" - from collections import namedtuple - - Choice = namedtuple('Choice', ['name', 'value']) - mapping = {"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"} - reverse = {v: k for k, v in mapping.items()} - - p_name = reverse.get(priority, "Normal") - choice = Choice(name=p_name, value=priority) - - await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True) - - bug_title = titre.strip() if titre else "Sans titre" - priority_name = choice.name - description_value = description - - description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n" - description_text += f"**Priorité:** {priority_name}\n\n" - description_text += f"**Description:**\n{description_value}" - - labels = ["bug", "manual-report", f"Priority::{choice.value}"] - - result = await gitlab_client.create_issue( - title=f"[MANUAL] {bug_title}", - description=description_text, - labels=labels, - ) - - if result: - issue_iid = result.get("iid") - save_report(issue_iid, interaction.user.id) - await interaction.edit_original_response( - content=( - "✅ Votre bug a été signalé avec succès ! " - "Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. " - f"[Voir l'issue]({result.get('web_url')})" - ) - ) - else: - await interaction.edit_original_response( - content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur." - ) + async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): + """Ouvre un formulaire pour signaler un bug.""" + modal = BugReportModal(None) + await interaction.response.send_modal(modal) @commands.slash_command( name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité pour le bot", ) - async def suggerer_fonctionnalite( - self, - interaction: disnake.ApplicationCommandInteraction, - titre: str = commands.Param(description="Titre de la suggestion"), - description: str = commands.Param(description="Détails de la fonctionnalité (copy-paste accepté !)"), - ): - """Suggère une fonctionnalité directement avec arguments - copy-paste supporté.""" - await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True) - - suggestion_title = titre.strip() if titre else "Sans titre" - description_value = description - - description_text = f"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n" - description_text += f"**Description:**\n{description_value}" - - labels = ["enhancement", "manual-suggestion"] - - result = await gitlab_client.create_issue( - title=f"[SUGGESTION] {suggestion_title}", - description=description_text, - labels=labels, - ) - - if result: - issue_iid = result.get("iid") - save_report(issue_iid, interaction.user.id) - await interaction.edit_original_response( - content=( - "✅ Votre suggestion a été envoyée avec succès ! " - "Merci de contribuer à l'amélioration du bot. " - f"[Voir l'issue]({result.get('web_url')})" - ) - ) - else: - await interaction.edit_original_response( - content="❌ Une erreur est survenue lors de l'envoi de la suggestion à GitLab." - ) + async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): + """Ouvre un formulaire pour suggérer une fonctionnalité.""" + modal = FeatureSuggestionModal() + await interaction.response.send_modal(modal) def setup(bot): From a9f577fc06bc50dd0b26caf616474c315e8fd804 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 15:13:56 +0200 Subject: [PATCH 21/23] refactor(bug_report): utiliser bouton + modal pour le formulaire Les commandes /signaler_bug et /suggerer_fonctionnalite affichent maintenant un embed avec un bouton qui ouvre le formulaire modal. Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 42 +++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 13292a8..85a7091 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -97,6 +97,16 @@ class BugReportModal(disnake.ui.Modal): ) +class BugReportView(disnake.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.primary, custom_id="open_bug_form") + async def open_bug_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): + modal = BugReportModal() + await interaction.response.send_modal(modal) + + class FeatureSuggestionModal(disnake.ui.Modal): def __init__(self): super().__init__( @@ -126,7 +136,7 @@ class FeatureSuggestionModal(disnake.ui.Modal): suggestion_title = (interaction.text_values.get("suggestion_title") or "").strip() or "Sans titre" description_value = interaction.text_values.get("suggestion_description") or "" - description_text = f"**Suggéré par:** {interaction.user} ({interaction.user.id})\n\n" + description_text = f"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n" description_text += f"**Description:**\n{description_value}" labels = ["enhancement", "manual-suggestion"] @@ -153,6 +163,16 @@ class FeatureSuggestionModal(disnake.ui.Modal): ) +class FeatureSuggestionView(disnake.ui.View): + def __init__(self): + super().__init__(timeout=None) + + @disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.green, custom_id="open_suggestion_form") + async def open_suggestion_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): + modal = FeatureSuggestionModal() + await interaction.response.send_modal(modal) + + class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot @@ -266,7 +286,7 @@ class BugReport(commands.Cog): note_obj_attrs = payload.get("object_attributes") or {} kuby_logger.info( f"[GitLab note] system={note_obj_attrs.get('system', False)} " - f"note_len={len(str(note_obj_attrs.get('note') or note_obj_attrs.get('body') or payload.get('note') or ''))}" + f"note_len={len(str(note_obj_attrs.get('note') or note_obj_attrs.get('body') or payload.get('note') or ''))}" ) app_info = await self.bot.application_info() @@ -437,8 +457,13 @@ class BugReport(commands.Cog): ) async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre un formulaire pour signaler un bug.""" - modal = BugReportModal(None) - await interaction.response.send_modal(modal) + embed = disnake.Embed( + title="🐛 Signaler un Bug", + description="Cliquez sur le bouton ci-dessous pour remplir le formulaire de signalement.", + color=disnake.Color.red() + ) + view = BugReportView() + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) @commands.slash_command( name="suggerer_fonctionnalite", @@ -446,8 +471,13 @@ class BugReport(commands.Cog): ) async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre un formulaire pour suggérer une fonctionnalité.""" - modal = FeatureSuggestionModal() - await interaction.response.send_modal(modal) + embed = disnake.Embed( + title="💡 Suggérer une Fonctionnalité", + description="Cliquez sur le bouton ci-dessous pour remplir le formulaire de suggestion.", + color=disnake.Color.green() + ) + view = FeatureSuggestionView() + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) def setup(bot): From 5913305be96093c533b9f6e139afeb1519acadef Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 15:17:35 +0200 Subject: [PATCH 22/23] =?UTF-8?q?fix(bug=5Freport):=20envoyer=20le=20modal?= =?UTF-8?q?=20directement=20sans=20message=20interm=C3=A9diaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les commandes /signaler_bug et /suggerer_fonctionnalite ouvrent maintenant le formulaire modal directement sans passer par un embed + bouton. Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 85a7091..dbb36c1 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -457,13 +457,8 @@ class BugReport(commands.Cog): ) async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre un formulaire pour signaler un bug.""" - embed = disnake.Embed( - title="🐛 Signaler un Bug", - description="Cliquez sur le bouton ci-dessous pour remplir le formulaire de signalement.", - color=disnake.Color.red() - ) - view = BugReportView() - await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + modal = BugReportModal() + await interaction.response.send_modal(modal) @commands.slash_command( name="suggerer_fonctionnalite", @@ -471,13 +466,8 @@ class BugReport(commands.Cog): ) async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre un formulaire pour suggérer une fonctionnalité.""" - embed = disnake.Embed( - title="💡 Suggérer une Fonctionnalité", - description="Cliquez sur le bouton ci-dessous pour remplir le formulaire de suggestion.", - color=disnake.Color.green() - ) - view = FeatureSuggestionView() - await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + modal = FeatureSuggestionModal() + await interaction.response.send_modal(modal) def setup(bot): From 2f5f4ce816e1a7ce353661b75473b2f456fbe774 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 15:21:07 +0200 Subject: [PATCH 23/23] corriger: correction d'une coquille dans le formulaire de signalement de bug Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index dbb36c1..9a6b450 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -49,7 +49,7 @@ class BugReportModal(disnake.ui.Modal): disnake.ui.TextInput( label="Description détaillée", style=disnake.TextInputStyle.paragraph, - placeholder="Que s'est-il passé ? Comment reproduire le bug ?", + placeholder="Que s'est-il passé ?", custom_id="bug_description", required=True, max_length=1000,