diff --git a/TODO.md b/TODO.md index e8df080..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +0,0 @@ -# TODO - Panel sanctions (components V2) - -- [ ] Revoir `commandes/moderation.py` : ajouter `panel_channel_id` dans la table `mod_config` -- [ ] Mettre à jour `/modconfig` avec un nouveau bouton/sélecteur “Salon sanctions” (ChannelSelect) (style components V2, callbacks dédiés) -- [ ] Ajouter un formatter “panneau sanctions” (liste bullets + citations/raison + date + modérateur + durée si TIMEOUT) -- [ ] Ajouter une fonction `send_sanction_panel(...)` et l’appeler dans `warn`, `timeout`, `kick`, `ban` (push automatique) -- [ ] Enrichir `/modpanel` pour afficher les sanctions actives récentes “proprement” (et garder l’UI actuelle) -- [ ] Nettoyer les callbacks (supprimer les `lambda` dans `ModConfigView` si présents) -- [ ] Tester localement : `/modconfig` -> config channel, puis exécuter des sanctions et vérifier l’affichage dans le salon diff --git a/bot.py b/bot.py index 9f94e5c..eca411f 100755 --- a/bot.py +++ b/bot.py @@ -13,7 +13,6 @@ import asyncio from src.logger import kuby_logger, log_performance import logging from src.advanced_logger import AdvancedLogger -import subprocess SENSITIVE_PERMISSIONS = { "administrator", "manage_guild", "manage_roles", "manage_channels", @@ -30,7 +29,6 @@ class RoleApprovalView(disnake.ui.View): @disnake.ui.button(label="Accepter", style=disnake.ButtonStyle.green, emoji="✅") async def accept(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): - # Check if member still in guild guild = self.member.guild current_member = guild.get_member(self.member.id) if not current_member: @@ -41,8 +39,7 @@ class RoleApprovalView(disnake.ui.View): try: await current_member.add_roles(*self.roles, reason="Restauration sécurisée approuvée par le propriétaire") await interaction.response.send_message(f"✅ Rôles sensibles restaurés pour {self.member.mention}.", ephemeral=True) - - # Update original message + components = [ disnake.ui.Container( disnake.ui.TextDisplay(f"✅ Vous avez approuvé la restauration des rôles pour {self.member.mention}."), @@ -57,8 +54,7 @@ class RoleApprovalView(disnake.ui.View): @disnake.ui.button(label="Refuser", style=disnake.ButtonStyle.red, emoji="✖️") async def deny(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await interaction.response.send_message(f"❌ Vous avez refusé la restauration pour {self.member.mention}.", ephemeral=True) - - # Update original message + components = [ disnake.ui.Container( disnake.ui.TextDisplay(f"❌ Vous avez refusé la restauration des rôles pour {self.member.mention}."), @@ -68,6 +64,7 @@ class RoleApprovalView(disnake.ui.View): await interaction.edit_original_response(embed=None, components=components, view=None) self.stop() + # Récupération et validation de l'ID d'application application_id_raw = os.getenv("APPLICATION_ID") kuby_logger.debug(f"Raw APPLICATION_ID: {application_id_raw}") @@ -80,11 +77,12 @@ except ValueError: # Configuration des intents Disnake intents = disnake.Intents.default() -intents.message_content = True -intents.members = True +intents.message_content = True +intents.members = True intents.voice_states = True kuby_logger.debug(f"Disnake intents configured: {intents}") + class MyBot(commands.Bot): def __init__(self): kuby_logger.debug("Initializing MyBot class") @@ -95,34 +93,16 @@ class MyBot(commands.Bot): command_sync_flags=commands.CommandSyncFlags.all() ) kuby_logger.info("MyBot instance created successfully") - + # Initialize advanced logger self.advanced_logger = AdvancedLogger(self) kuby_logger.info("✅ Advanced logger initialized") - # Start the Flask Webhook Server - self.flask_process = None - try: - flask_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "serveur_flask.py") - if os.path.exists(flask_path): - self.flask_process = subprocess.Popen( - [sys.executable, flask_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=os.path.dirname(flask_path) - ) - kuby_logger.info(f"✅ Serveur Flask démarré (PID: {self.flask_process.pid})") - else: - kuby_logger.warning(f"⚠️ Fichier serveur_flask.py introuvable") - except Exception as e: - kuby_logger.error(f"❌ Impossible de démarrer le serveur Flask: {e}") - + # Flask est géré par PM2 (kuby-flask), pas besoin de le lancer ici async def do_async_setup(self): - # --- INITIALIZATION LOGIC (Called before start() in kuby.py) --- kuby_logger.info("🚀 Configuration initiale du bot Kuby...") - # Charger les extensions extensions = [ "commandes.whitelist", "commandes.whitelist_monitor", @@ -154,31 +134,6 @@ class MyBot(commands.Bot): except Exception as e: kuby_logger.error(f"❌ Erreur lors du chargement de {extension} : {e}", exc_info=True) - # --- SYSTÈME DE WHITELIST (DÉDIÉ SLASH COMMANDS) --- - async def global_slash_whitelist_check(inter: disnake.ApplicationCommandInteraction): - try: - if await self.is_owner(inter.author): - return True - command_name = inter.data.name - if command_name in ["ticketconfig", "ticketpanel", "ticket_config", "ticket_panel", "ticket_whitelist_config", "invitations"]: - return True - whitelist_cog = self.get_cog("WhitelistMonitor") - if not whitelist_cog: - return True - is_whitelisted = whitelist_cog.is_whitelisted(inter.guild.id, inter.author.id) - if not is_whitelisted: - kuby_logger.info(f"🚫 [Check] Accès refusé : {inter.author} sur /{command_name}") - msg = "❌ Vous n'êtes pas autorisé à utiliser cette commande. Veuillez demander à être ajouté à la whitelist." - if not inter.response.is_done(): - await inter.response.send_message(msg, ephemeral=True) - return False - return True - except Exception as e: - kuby_logger.error(f"⚠️ [Check] Erreur critique: {e}", exc_info=True) - return True - - # self.add_app_command_check(global_slash_whitelist_check) - # --- LOGGING DES INTERACTIONS --- @self.listen("on_interaction") async def log_interaction(inter: disnake.Interaction): @@ -187,17 +142,6 @@ class MyBot(commands.Bot): kuby_logger.info("✅ Configuration terminée") - async def close(self): - if hasattr(self, 'flask_process') and self.flask_process: - kuby_logger.info("Arrêt du serveur Flask interne...") - self.flask_process.terminate() - try: - self.flask_process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.flask_process.kill() - await super().close() - - # --- ÉVÉNEMENTS RESTAURÉS --- async def on_ready(self): kuby_logger.info(f"🤖 Bot connecté : {self.user} (ID : {self.user.id})") kuby_logger.info(f"📊 Serveurs : {len(self.guilds)}") @@ -224,23 +168,16 @@ class MyBot(commands.Bot): roles_to_add = [guild.get_role(rid) for rid in previous_roles.get("role_ids", [])] roles_to_add = [r for r in roles_to_add if r and r < guild.me.top_role] if roles_to_add: - # Separate roles into safe and sensitive safe_roles = [] sensitive_roles = [] - + for role in roles_to_add: - is_sensitive = False - for perm in SENSITIVE_PERMISSIONS: - if getattr(role.permissions, perm, False): - is_sensitive = True - break - + is_sensitive = any(getattr(role.permissions, perm, False) for perm in SENSITIVE_PERMISSIONS) if is_sensitive: sensitive_roles.append(role) else: safe_roles.append(role) - # Restore safe roles automatically if safe_roles: retries = 3 for i in range(retries): @@ -250,8 +187,15 @@ class MyBot(commands.Bot): break except (disnake.HTTPException, disnake.GatewayNotFound, asyncio.TimeoutError) as e: is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504] - if not isinstance(e, disnake.HTTPException): is_transient = True - + if not isinstance(e, disnake.HTTPException): + is_transient = True + # 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 @@ -261,7 +205,6 @@ class MyBot(commands.Bot): kuby_logger.error(f"Error restoring safe roles for {member}: {e}") break - # Handle sensitive roles with owner approval if sensitive_roles: try: owner = guild.owner or await guild.fetch_member(guild.owner_id) @@ -298,19 +241,23 @@ class MyBot(commands.Bot): kuby_logger.error(f"Error in leave notification: {e}") async def on_message_delete(self, message): - if message.author.bot: return + if message.author.bot: + return if hasattr(self, 'advanced_logger'): self.advanced_logger.log_message_event(message, "deleted") async def on_message_edit(self, before, after): - if before.author.bot or before.content == after.content: return + if before.author.bot or before.content == after.content: + return if hasattr(self, 'advanced_logger'): self.advanced_logger.log_message_event(before, "edited", { - "before_content": before.content, "after_content": after.content + "before_content": before.content, + "after_content": after.content }) async def on_voice_state_update(self, member, before, after): - if member.bot: return + if member.bot: + return if before.channel != after.channel and hasattr(self, 'advanced_logger'): if before.channel is None: self.advanced_logger.log_voice_event(member, after.channel, "joined") @@ -322,9 +269,7 @@ class MyBot(commands.Bot): async def on_command_error(self, ctx, error): if isinstance(error, commands.CommandNotFound): - return # Ignorer les erreurs "Command not found" pour éviter de polluer les logs (ex: !!) + return if isinstance(error, commands.CheckFailure): return - kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True) - -bot = MyBot() \ No newline at end of file + kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True) \ No newline at end of file diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 5fe58a6..9a6b450 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 "" @@ -96,7 +87,7 @@ class BugReportModal(disnake.ui.Modal): await interaction.edit_original_response( content=( "✅ Votre bug a été signalé avec succès ! " - "Gameur a bien été averti et le bug sera réglé dans la prochaine mise à jour. " + "Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. " f"[Voir l'issue]({result.get('web_url')})" ) ) @@ -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,18 +163,34 @@ 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 async def cog_load(self): + # Disnake appelle automatiquement cette méthode dès que la boucle d'événements est active. + self.bot.loop.create_task(self._start_webhook_server()) + + async def _start_webhook_server(self): + await self.bot.wait_until_ready() + self.web_app = web.Application() self.web_app.router.add_post('/bot_event', self.handle_bot_event) self.runner = web.AppRunner(self.web_app) await self.runner.setup() - max_retries = 5 - retry_delay = 2 + max_retries = 10 + retry_delay = 5 for attempt in range(1, max_retries + 1): try: @@ -184,7 +201,7 @@ class BugReport(commands.Cog): ) return except OSError as e: - if e.errno == 98: # Address already in use + if getattr(e, 'errno', None) == 98: # Address already in use if attempt < max_retries: kuby_logger.warning( f"Port 5001 already in use, retrying in {retry_delay}s... ({attempt}/{max_retries})" @@ -259,6 +276,19 @@ class BugReport(commands.Cog): if not user: return web.json_response({"status": "user not found"}, status=200) + # DEBUG: log brute payload minimal pour comprendre pourquoi aucun DM n'est envoyé + kuby_logger.info( + f"[GitLab webhook] event_type={event_type} issue_iid={issue_iid} " + f"tracked_user_id={user_id} payload_keys={list(payload.keys())[:20]}" + ) + + if event_type == "note": + note_obj_attrs = payload.get("object_attributes") or {} + kuby_logger.info( + f"[GitLab note] system={note_obj_attrs.get('system', False)} " + f"note_len={len(str(note_obj_attrs.get('note') or note_obj_attrs.get('body') or payload.get('note') or ''))}" + ) + app_info = await self.bot.application_info() dev = app_info.owner issue_title = payload.get("object_attributes", {}).get("title", f"#{issue_iid}") @@ -283,24 +313,18 @@ class BugReport(commands.Cog): current_labels_str = "Mis à jour" try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "🛠️ Mise à jour de votre signalement !", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe." - ), - disnake.ui.TextDisplay( - f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}" - ), - ) - ] + embed = disnake.Embed( + title="🛠️ Mise à jour de votre signalement !", + description=( + f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.\n\n" + f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}" + ), + color=disnake.Color.blue() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -310,11 +334,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( @@ -332,25 +355,19 @@ class BugReport(commands.Cog): if is_closing_now: try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "🛠️ Bug / Suggestion Terminé(e) !", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu." - ), - disnake.ui.TextDisplay( - "🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !" - ), - disnake.ui.TextDisplay("✨ Merci pour votre aide !"), - ) - ] + embed = disnake.Embed( + title="🛠️ Bug / Suggestion Terminé(e) !", + description=( + f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.\n\n" + f"🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !\n" + f"✨ Merci pour votre aide !" + ), + color=disnake.Color.green() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -378,8 +395,12 @@ class BugReport(commands.Cog): note_attr = payload.get("object_attributes", {}) is_system = note_attr.get("system", False) + kuby_logger.info( + f"[GitLab note] dispatch started issue_iid={issue_iid} is_system={is_system} " + f"note_field_present={bool(note_attr.get('note') or note_attr.get('body'))}" + ) + if not is_system: - # GitLab peut envoyer des champs différents selon version/config note_body = ( note_attr.get("note") or note_attr.get("body") @@ -389,26 +410,20 @@ class BugReport(commands.Cog): ) author_name = payload.get("user", {}).get("name", "Développeur") - try: - components = [ - disnake.ui.Container( - disnake.ui.Section( - "💬 Nouveau commentaire sur votre signalement", - accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url), - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay( - f"Le développeur a répondu à votre rapport **{issue_title}** :" - ), - disnake.ui.TextDisplay(f">>> {note_body}"), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay(f"✍️ **Par :** {author_name}"), - ) - ] + embed = disnake.Embed( + title="💬 Nouveau commentaire sur votre signalement", + description=( + f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n" + f">>> {note_body}\n\n" + f"✍️ **Par :** {author_name}" + ), + color=disnake.Color.orange() + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) try: - await user.send(components=components) + await user.send(embed=embed) kuby_logger.info( f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" ) @@ -425,7 +440,10 @@ class BugReport(commands.Cog): 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}") + kuby_logger.error( + f"Erreur inattendue dans la notification de commentaire : {e}", + exc_info=True, + ) return web.json_response({"status": "success"}, status=200) @@ -437,35 +455,20 @@ 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"}, - ), - ): - # Simulate app_commands.Choice behavior for existing code - from collections import namedtuple - - Choice = namedtuple('Choice', ['name', 'value']) - mapping = {"Basse": "Low", "Normale": "Normal", "Haute": "High", "Urgente": "Urgent"} - reverse = {v: k for k, v in mapping.items()} - - # priority vient du value GitLab (Low/Normal/High/Urgent) - p_name = reverse.get(priority, "Normal") - choice = Choice(name=p_name, value=priority) - - 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): - bot.add_cog(BugReport(bot)) - + bot.add_cog(BugReport(bot)) \ No newline at end of file diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py new file mode 100644 index 0000000..d9569b9 --- /dev/null +++ b/commandes/gitlab_integration.py @@ -0,0 +1,221 @@ +import os +import json +import asyncio +from datetime import datetime, timezone +from typing import Dict, Any, List + +import aiohttp +import disnake +from disnake.ext import commands, tasks + +# Environment variables for GitLab access +GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") +GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path + +if not GITLAB_TOKEN or not GITLAB_PROJECT_ID: + raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID") + +# File to persist issue tracking information +ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") + +def load_issue_tracking() -> Dict[str, Any]: + if os.path.exists(ISSUE_TRACK_FILE): + with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: + return json.load(f) + return {} + +def save_issue_tracking(data: Dict[str, Any]) -> None: + with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +class GitLabIntegration(commands.Cog): + """Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.issue_data: Dict[str, Any] = load_issue_tracking() # key: issue_iid (str) -> {"requester_id": int, "last_note_id": int} + self.check_new_notes.start() + + def cog_unload(self): + self.check_new_notes.cancel() + + async def _create_gitlab_issue(self, title: str, description: str, labels: List[str]) -> Dict[str, Any]: + """Create an issue on GitLab and return the JSON response.""" + url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" + headers = { + "PRIVATE-TOKEN": GITLAB_TOKEN, + "Content-Type": "application/json", + } + payload = { + "title": title, + "description": description, + "labels": ",".join(labels), + } + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as resp: + resp.raise_for_status() + return await resp.json() + + async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: + """Fetch all notes (comments) for a given issue.""" + url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes" + headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]): + """Send a private DM to the requester informing them of a new comment.""" + user = await self.bot.fetch_user(requester_id) + author = note.get("author", {}).get("name", "Someone") + body = note.get("body", "") + embed = disnake.Embed( + title=f"Nouveau commentaire sur votre demande GitLab (#{issue_iid})", + description=body, + color=disnake.Color.blurple(), + timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc), + ) + embed.set_footer(text=f"Commentaire de {author}") + try: + await user.send(embed=embed) + except disnake.HTTPException: + # The user may have DMs disabled; ignore silently. + pass + + @tasks.loop(minutes=1) + async def check_new_notes(self): + """Periodic task that checks for new notes on tracked issues and notifies requesters.""" + for issue_iid_str, info in list(self.issue_data.items()): + issue_iid = int(issue_iid_str) + try: + notes = await self._get_issue_notes(issue_iid) + except Exception as exc: + # Skip this issue if we cannot fetch notes. + continue + # Find the newest note ID + if not notes: + continue + latest_note = max(notes, key=lambda n: n["id"]) + last_known_id = info.get("last_note_id", 0) + if latest_note["id"] > last_known_id: + # New comment detected + await self._notify_requester(info["requester_id"], issue_iid, latest_note) + # Update stored last_note_id + self.issue_data[issue_iid_str]["last_note_id"] = latest_note["id"] + save_issue_tracking(self.issue_data) + + # ---------- Slash command: /suggerer_fonctionnalite ---------- + @commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet") + async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction): + await inter.response.send_modal(modal=FeatureModal(self)) + + # ---------- Slash command: /signaler_bug ---------- + @commands.slash_command(name="notify_deploy", description="Envoyer un DM de confirmation du déploiement") + async def notify_deploy(self, inter: disnake.ApplicationCommandInteraction): + """DM the user that the deployment is done.""" + try: + await inter.author.send("✅ C'est fait ! Le code a été déployé sur la branche `main`.") + await inter.response.send_message("✅ DM de confirmation envoyé.", ephemeral=True) + except disnake.HTTPException: + await inter.response.send_message("⚠️ Impossible d'envoyer le DM (DMs désactivés).", ephemeral=True) + + async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): + await inter.response.send_modal(modal=BugModal(self)) + +# ---------- Modals ---------- +class FeatureModal(disnake.Modal): + def __init__(self, cog: GitLabIntegration): + components = [ + disnake.ui.TextInput( + label="Titre de la fonctionnalité", + custom_id="title", + style=disnake.TextInputStyle.short, + max_length=100, + required=True, + ), + disnake.ui.TextInput( + label="Description détaillée", + custom_id="description", + style=disnake.TextInputStyle.paragraph, + max_length=2000, + required=True, + ), + ] + super().__init__( + title="Suggestion de fonctionnalité", + custom_id="feature_modal", + components=components, + ) + self.cog = cog + + async def callback(self, inter: disnake.ModalInteraction): + title = inter.text_values["title"] + description = inter.text_values["description"] + await inter.response.defer(ephemeral=True) + try: + issue = await self.cog._create_gitlab_issue( + title=title, + description=description, + labels=["feature", "discord-bot"], + ) + # Store tracking info + issue_iid = str(issue["iid"]) + self.cog.issue_data[issue_iid] = { + "requester_id": inter.author.id, + "last_note_id": 0, + "type": "feature", + } + save_issue_tracking(self.cog.issue_data) + await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + except Exception as e: + await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}") + +class BugModal(disnake.Modal): + def __init__(self, cog: GitLabIntegration): + components = [ + disnake.ui.TextInput( + label="Titre du bug", + custom_id="title", + style=disnake.TextInputStyle.short, + max_length=100, + required=True, + ), + disnake.ui.TextInput( + label="Description et étapes pour reproduire", + custom_id="description", + style=disnake.TextInputStyle.paragraph, + max_length=2000, + required=True, + ), + ] + super().__init__( + title="Signalement de bug", + custom_id="bug_modal", + components=components, + ) + self.cog = cog + + async def callback(self, inter: disnake.ModalInteraction): + title = inter.text_values["title"] + description = inter.text_values["description"] + await inter.response.defer(ephemeral=True) + try: + issue = await self.cog._create_gitlab_issue( + title=title, + description=description, + labels=["bug", "discord-bot"], + ) + issue_iid = str(issue["iid"]) + self.cog.issue_data[issue_iid] = { + "requester_id": inter.author.id, + "last_note_id": 0, + "type": "bug", + } + save_issue_tracking(self.cog.issue_data) + await inter.edit_original_message(content=f"✅ Bug créé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})") + except Exception as e: + await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}") + +def setup(bot: commands.Bot): + bot.add_cog(GitLabIntegration(bot)) 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 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 diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index aecc64f..852db08 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) @@ -185,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)}" ) ) ) @@ -542,9 +539,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 +593,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 +702,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 +2301,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): @@ -2646,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) @@ -2674,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) @@ -2705,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), @@ -2722,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 @@ -2745,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) @@ -2757,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...", @@ -2773,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) @@ -2851,7 +3153,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 @@ -2885,23 +3187,49 @@ 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 - # 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] cat_ routing error: {e}", exc_info=True) @@ -2934,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() @@ -3259,7 +3661,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", {}) ) diff --git a/kuby.py b/kuby.py index ff01fee..d73ca48 100755 --- a/kuby.py +++ b/kuby.py @@ -2,7 +2,7 @@ import asyncio import os import sys from dotenv import load_dotenv -from bot import bot +from bot import MyBot # Modification ici : on importe la classe from src.logger import kuby_logger, log_performance kuby_logger.info("🚀 Lancement du bot Kuby...") @@ -28,6 +28,9 @@ kuby_logger.info("✅ Token Discord chargé avec succès") async def main(): kuby_logger.info("🔄 Démarrage de la connexion Discord...") try: + # Modification majeure : l'objet bot est instancié ici, au sein de la boucle active + bot = MyBot() + await bot.do_async_setup() kuby_logger.info("🔗 Connexion établie avec Discord...") await bot.start(TOKEN) 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: