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/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 1c37d2a..392e09d 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=[ @@ -58,11 +49,11 @@ 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, - ) + ), ] ) @@ -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 "" @@ -106,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__( @@ -135,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"] @@ -162,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 @@ -252,7 +263,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() @@ -300,11 +311,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( @@ -312,8 +322,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}") @@ -343,18 +351,15 @@ class BugReport(commands.Cog): 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}).") + 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} (DMs désactivés)." + 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}") @@ -400,9 +405,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}", @@ -419,31 +427,19 @@ class BugReport(commands.Cog): name="signaler_bug", description="Signaler un bug aux développeurs", ) - async def signaler_bug( - self, - interaction: disnake.ApplicationCommandInteraction, - priority: str = commands.Param( - description="Priorité du bug", - choices={"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"}, - ), - ): - 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_modal(BugReportModal(choice)) + async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): + """Ouvre un formulaire pour signaler un bug.""" + modal = BugReportModal() + 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): - await interaction.response.send_modal(FeatureSuggestionModal()) + """Ouvre un formulaire pour suggérer une fonctionnalité.""" + modal = FeatureSuggestionModal() + await interaction.response.send_modal(modal) def setup(bot): 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)) diff --git a/commandes/modules_security/rules.py b/commandes/modules_security/rules.py index 3da5a3e..bf1ac7d 100644 --- a/commandes/modules_security/rules.py +++ b/commandes/modules_security/rules.py @@ -12,6 +12,14 @@ SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json") # Créer le dossier data/ s'il n'existe pas os.makedirs(DATA_DIR, exist_ok=True) +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): @@ -99,7 +107,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: @@ -438,10 +446,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." @@ -459,19 +468,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 diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 4aae0fb..0d416a8 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) @@ -547,9 +544,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) @@ -598,7 +598,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 @@ -707,6 +707,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""" @@ -2106,31 +2306,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): @@ -2655,18 +2908,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) @@ -2683,17 +2945,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) @@ -2714,9 +2979,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), @@ -2731,17 +3007,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 @@ -2754,7 +3039,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) @@ -2766,7 +3051,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...", @@ -2782,23 +3067,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) @@ -2894,13 +3196,40 @@ class TicketCommands(commands.Cog): else: cat_id = parts[1] if len(parts) > 1 else None - 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 - # 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) + 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] catv2_ routing error: {e}", exc_info=True) @@ -2933,6 +3262,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() @@ -3258,18 +3661,93 @@ 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") 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) diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index 22ca9f5..7381f93 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", {}) ) diff --git a/src/advanced_logger.py b/src/advanced_logger.py index 72b25dd..9c65f39 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -107,39 +107,46 @@ 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: 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 + + # 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: - 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 - + 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: