import disnake from disnake.ext import commands from utils.gitlab_client import gitlab_client from aiohttp import web import logging import json import os import asyncio kuby_logger = logging.getLogger("KubyBot") # Utilisation de chemins absolus pour la persistance après reboot BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") def save_report(issue_iid, user_id): os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True) try: data = {} if os.path.exists(REPORTS_FILE): try: with open(REPORTS_FILE, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError: kuby_logger.warning("REPORTS_FILE corrupted, re-initializing to avoid tracking loss.") data[str(issue_iid)] = user_id temp_file = f"{REPORTS_FILE}.tmp" with open(temp_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=4) os.replace(temp_file, REPORTS_FILE) except Exception as e: kuby_logger.error(f"Error saving report to JSON: {e}") class BugReportModal(disnake.ui.Modal): def __init__(self, priority_choice): super().__init__(title="Signaler un Bug", components=[self.bug_title, self.description]) # Sécurisation: éviter les valeurs None qui finissent dans GitLab if priority_choice is None: self.priority_choice = type( "PriorityChoice", (), {"name": "Normal", "value": "Normal"}, )() else: self.priority_choice = priority_choice bug_title = disnake.ui.TextInput( label="Titre du bug", placeholder="Décrivez brièvement le problème...", required=True, max_length=100, ) description = disnake.ui.TextInput( label="Description détaillée", style=disnake.TextInputStyle.paragraph, placeholder="Que s'est-il passé ? Comment reproduire le bug ?", required=True, max_length=1000, ) async def callback(self, interaction: disnake.Interaction): await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True) bug_title = (self.bug_title.value or "").strip() 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" description_value = self.description.value or "" description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n" description_text += f"**Priorité:** {priority_name}\n\n" description_text += f"**Description:**\n{description_value}" labels = ["bug", "manual-report", f"Priority::{priority_value}"] result = await gitlab_client.create_issue( title=f"[MANUAL] {bug_title}", description=description_text, labels=labels, ) if result: issue_iid = result.get("iid") save_report(issue_iid, interaction.user.id) await interaction.edit_original_response( content=( "✅ Votre bug a été signalé avec succès ! " "Gameur a bien été averti et le bug sera réglé dans la prochaine mise à jour. " f"[Voir l'issue]({result.get('web_url')})" ) ) else: await interaction.edit_original_response( content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur." ) class FeatureSuggestionModal(disnake.ui.Modal): def __init__(self): super().__init__(title="Suggérer une fonctionnalité", components=[self.suggestion_title, self.description]) suggestion_title = disnake.ui.TextInput( label="Titre de la suggestion", placeholder="Que voulez-vous ajouter ?", required=True, max_length=100, ) description = disnake.ui.TextInput( label="Détails de la fonctionnalité", style=disnake.TextInputStyle.paragraph, placeholder="Décrivez comment cela devrait fonctionner...", required=True, max_length=1000, ) async def callback(self, interaction: disnake.Interaction): await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True) suggestion_title = (self.suggestion_title.value or "").strip() or "Sans titre" description_value = self.description.value or "" description_text = f"**Suggéré par:** {interaction.user} ({interaction.user.id})\n\n" description_text += f"**Description:**\n{description_value}" labels = ["enhancement", "manual-suggestion"] result = await gitlab_client.create_issue( title=f"[SUGGESTION] {suggestion_title}", description=description_text, labels=labels, ) if result: issue_iid = result.get("iid") save_report(issue_iid, interaction.user.id) await interaction.edit_original_response( content=( "✅ Votre suggestion a été envoyée avec succès ! " "Merci de contribuer à l'amélioration du bot. " f"[Voir l'issue]({result.get('web_url')})" ) ) else: await interaction.edit_original_response( content="❌ Une erreur est survenue lors de l'envoi de la suggestion à GitLab." ) class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot async def cog_load(self): self.web_app = web.Application() self.web_app.router.add_post('/bot_event', self.handle_bot_event) self.runner = web.AppRunner(self.web_app) await self.runner.setup() max_retries = 5 retry_delay = 2 for attempt in range(1, max_retries + 1): try: self.site = web.TCPSite(self.runner, '127.0.0.1', 5001) await self.site.start() kuby_logger.info( f"Internal Bot Webhook Server started on 127.0.0.1:5001 (Attempt {attempt})" ) return except OSError as e: if e.errno == 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})" ) await asyncio.sleep(retry_delay) else: kuby_logger.error(f"Failed to bind to port 5001 after {max_retries} attempts.") raise e else: raise e def cog_unload(self): if hasattr(self, 'runner'): async def cleanup(): try: if hasattr(self, 'site'): await self.site.stop() await self.runner.cleanup() kuby_logger.info("Internal Bot Webhook Server stopped and cleaned up.") except Exception as e: kuby_logger.error(f"Error during webhook server cleanup: {e}") self.bot.loop.create_task(cleanup()) async def handle_bot_event(self, request): provided_token = request.headers.get("X-Gitlab-Token") # On ignore la vérification du token pour faciliter le relais local try: if not self.bot.is_ready(): kuby_logger.info("Webhook received but bot is not ready yet. Waiting up to 10s...") try: await asyncio.wait_for(self.bot.wait_until_ready(), timeout=10.0) except asyncio.TimeoutError: kuby_logger.warning("Bot still not ready after 10s. Proceeding anyway (best effort).") payload = await request.json() event_type = payload.get("object_kind") if event_type not in ["issue", "note"]: return web.json_response({"status": "ignored"}, status=200) if event_type == "issue": issue_iid = payload.get("object_attributes", {}).get("iid") else: issue_iid = payload.get("issue", {}).get("iid") if not issue_iid: return web.json_response({"status": "no issue id"}, status=200) if not os.path.exists(REPORTS_FILE): return web.json_response({"status": "no reports file"}, status=200) try: with open(REPORTS_FILE, "r", encoding="utf-8") as f: reports = json.load(f) except json.JSONDecodeError: kuby_logger.error("REPORTS_FILE corrupted during read!") return web.json_response({"status": "corrupted file"}, status=500) user_id = reports.get(str(issue_iid)) if not user_id: return web.json_response({"status": "untracked issue"}, status=200) user = self.bot.get_user(user_id) if not user: try: user = await self.bot.fetch_user(user_id) except: pass if not user: return web.json_response({"status": "user not found"}, status=200) app_info = await self.bot.application_info() dev = app_info.owner issue_title = payload.get("object_attributes", {}).get("title", f"#{issue_iid}") if event_type == "note": issue_title = payload.get("issue", {}).get("title", f"#{issue_iid}") if event_type == "issue": action = payload.get("object_attributes", {}).get("action") changes = payload.get("changes", {}) if "labels" in changes: curr_labels = [l.get("title") for l in changes["labels"].get("current", []) if l.get("title")] display_labels = [ l for l in curr_labels if not l.startswith("Priority::") and l not in ["bug", "manual-report", "manual-suggestion"] ] if display_labels: current_labels_str = ", ".join(display_labels) else: 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}" ), ) ] try: await user.send(components=components) kuby_logger.info( f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) if dev and dev.id != user.id: await dev.send( f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de statut " 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 ): kuby_logger.warning( f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés (Code: {getattr(e, 'code', 'Unknown')})" ) if dev and dev.id != user.id: await dev.send( f"⚠️ [DM BLOQUÉ] {user} ({user.id}) a ses MPs fermés. Impossible de notifier pour l'issue #{issue_iid}." ) 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}") state = payload.get("object_attributes", {}).get("state") is_closing_now = action == "close" or (state == "closed" and "state_id" in changes) 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 !"), ) ] try: await user.send(components=components) kuby_logger.info( f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}" ) if dev and dev.id != user.id: await dev.send( f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}." ) except (disnake.Forbidden, disnake.HTTPException) as e: if isinstance(e, disnake.Forbidden) or ( isinstance(e, disnake.HTTPException) and e.code == 50007 ): kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}).") 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)." ) 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}") elif event_type == "note": note_attr = payload.get("object_attributes", {}) is_system = note_attr.get("system", False) if not is_system: note_body = note_attr.get("note", "") 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}"), ) ] try: await user.send(components=components) kuby_logger.info( f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}" ) if dev and dev.id != user.id: await dev.send( f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu votre commentaire pour l'issue #{issue_iid}." ) 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)." ) except Exception as e: kuby_logger.error(f"Erreur inattendue dans la notification de commentaire : {e}") return web.json_response({"status": "success"}, status=200) except Exception as e: kuby_logger.error(f"Error handling bot event: {e}") return web.json_response({"status": "error", "message": str(e)}, status=500) @commands.slash_command( 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)) @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()) def setup(bot): bot.add_cog(BugReport(bot))