From 59c91e44137e05ea5e2fa795be8a26d53b2c504b Mon Sep 17 00:00:00 2001 From: Lowei Date: Thu, 21 May 2026 22:46:14 +0200 Subject: [PATCH 01/17] =?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, causant une boucle jusqu'à StackOverflow. On ignore désormais silencieusement l'échec si même le dev ne peut recevoir de DM. 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 9c7ae2e..5fe58a6 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -418,9 +418,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 9546e130010530d331d5ef260aff136c2ea814da Mon Sep 17 00:00:00 2001 From: Lowei Date: Thu, 21 May 2026 22:48:19 +0200 Subject: [PATCH 02/17] =?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 03/17] =?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 04/17] =?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 05/17] 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 06/17] =?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 07/17] =?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 08/17] =?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 09/17] =?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 10/17] =?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 11/17] 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 12/17] 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 13/17] =?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 14/17] 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, From 9ddab71d83e2a56a998cda10e5464bb6782d38d8 Mon Sep 17 00:00:00 2001 From: Lowei Date: Sat, 23 May 2026 15:30:01 +0200 Subject: [PATCH 15/17] fix(bug_report): separate exception handlers for DM blocked errors Separate disnake.Forbidden and disnake.HTTPException handlers to properly catch error code 50007 (DMs disabled) instead of falling through to the outer "unexpected error" handler. --- commandes/bug_report.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 9a6b450..3044ae0 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -375,14 +375,18 @@ class BugReport(commands.Cog): await dev.send( f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}." ) - except (disnake.Forbidden, disnake.HTTPException) as e: - if isinstance(e, disnake.Forbidden) or ( - isinstance(e, disnake.HTTPException) and e.code == 50007 - ): - kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}).") + except disnake.Forbidden: + kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.") + if dev and dev.id != user.id: + await dev.send( + f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." + ) + except disnake.HTTPException as e: + if e.code == 50007: + kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - code 50007 (DMs peut-être désactivés).") if dev and dev.id != user.id: await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." + f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (code 50007)." ) else: kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de clôture à {user}: {e}") From 6e314f081df26d3c6e8275c7acee8e65bca41e6b Mon Sep 17 00:00:00 2001 From: Lowei Date: Mon, 25 May 2026 17:17:37 +0200 Subject: [PATCH 16/17] =?UTF-8?q?fix(bug=5Freport):=20unifier=20les=20hand?= =?UTF-8?q?lers=20d'exception=20pour=20ne=20plus=20afficher=20"erreur=20in?= =?UTF-8?q?attendue"=20lors=20de=20MPs=20bloqu=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- commandes/bug_report.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 3044ae0..d72647e 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -345,8 +345,6 @@ class BugReport(commands.Cog): ) else: kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de statut à {user}: {e}") - except Exception as e: - kuby_logger.error(f"Erreur lors de l'envoi du MP de statut à {user}: {e}") except Exception as e: kuby_logger.error(f"Erreur inattendue dans la notification de label : {e}") @@ -375,23 +373,16 @@ class BugReport(commands.Cog): await dev.send( f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}." ) - except disnake.Forbidden: - kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.") - if dev and dev.id != user.id: - await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)." - ) - except disnake.HTTPException as e: - if e.code == 50007: - kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - code 50007 (DMs peut-être désactivés).") + except (disnake.Forbidden, disnake.HTTPException) as e: + error_code = getattr(e, 'code', None) + if isinstance(e, disnake.Forbidden) or error_code == 50007: + kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.") if dev and dev.id != user.id: await dev.send( - f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (code 50007)." + f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid}." ) else: kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de clôture à {user}: {e}") - except Exception as e: - kuby_logger.error(f"Erreur lors de l'envoi du MP de clôture à {user}: {e}") except Exception as e: kuby_logger.error(f"Erreur inattendue dans la notification de clôture : {e}") From 101f078f51f279709a3dcf41f26a979c3e969f82 Mon Sep 17 00:00:00 2001 From: Lowei Date: Mon, 25 May 2026 17:19:45 +0200 Subject: [PATCH 17/17] =?UTF-8?q?fix(ticket):=20corriger=20l'=C3=A9chec=20?= =?UTF-8?q?d'interaction=20caused=20par=20un=20custom=5Fid=20dynamique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le custom_id des boutons de catégorie incluait un snowflake basé sur l'heure courante, ce qui changeait à chaque affichage du message et empêchait Discord de matcher le bouton enregistré. Co-Authored-By: Claude Opus 4.6 --- commandes/ticket/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 852db08..9fba48c 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}" ) ) ) @@ -3745,12 +3745,9 @@ class TicketCommands(commands.Cog): @commands.slash_command(name="ticket", description="Ouvre le panel des tickets") async def ticket_main(self, inter: disnake.ApplicationCommandInteraction): """Ouvre le panel des tickets""" - await inter.response.defer(ephemeral=True) - config = self.storage.load_config(inter.guild.id) - view = TicketPanelView(self.bot, config, self.storage) - await inter.edit_original_response(components=view.get_components_v2()) + await inter.response.send_message(components=view.get_components_v2(), view=view, ephemeral=True) @commands.slash_command(name="ticketpanel", description="Envoie le panel de création de tickets dans un canal") @commands.has_permissions(administrator=True)