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 from typing import Dict, Any, Optional kuby_logger = logging.getLogger("KubyBot") BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ⚡ NOUVEAU: Utilise le même fichier que gitlab_integration.py TRACKING_FILE = os.path.join(BASE_DIR, "commandes", "gitlab_issues.json") LEGACY_REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json") def load_tracking(): """Charge le tracking depuis gitlab_issues.json (avec migration automatique)""" tracking = {} if os.path.exists(TRACKING_FILE): try: with open(TRACKING_FILE, "r", encoding="utf-8") as f: tracking = json.load(f) except Exception as e: kuby_logger.error(f"Erreur chargement gitlab_issues.json: {e}") # Migration automatique depuis l'ancien fichier if os.path.exists(LEGACY_REPORTS_FILE): try: with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f: legacy_data = json.load(f) for issue_iid, user_id in legacy_data.items(): if issue_iid not in tracking: tracking[issue_iid] = { "requester_id": user_id, "last_note_id": 0, "type": "legacy_report" } kuby_logger.info(f"Migration automatique: {len(legacy_data)} issues fusionnées") except Exception as e: kuby_logger.error(f"Erreur migration gitlab_reports.json: {e}") return tracking def save_tracking(data: Dict[str, Any]) -> None: """Sauvegarde dans gitlab_issues.json""" os.makedirs(os.path.dirname(TRACKING_FILE), exist_ok=True) temp_file = f"{TRACKING_FILE}.tmp" with open(temp_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) os.replace(temp_file, TRACKING_FILE) class BugReportModal(disnake.ui.Modal): def __init__(self, cog): self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking components = [ disnake.ui.TextInput( label="Titre du bug", placeholder="Décrivez brièvement le problème...", custom_id="bug_title", required=True, max_length=100, ), disnake.ui.TextInput( label="Description détaillée", style=disnake.TextInputStyle.paragraph, placeholder="Que s'est-il passé ?", custom_id="bug_description", required=True, max_length=1000, ), ] super().__init__( title="Signaler un Bug", custom_id="bug_modal", components=components, ) async def callback(self, interaction: disnake.ModalInteraction): await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True) bug_title = (interaction.text_values.get("bug_title") or "").strip() if not bug_title: bug_title = "Sans titre" priority_name = "Normale" priority_value = "Normal" description_value = interaction.text_values.get("bug_description") 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 = str(result.get("iid")) # ⚡ NOUVEAU: Utilise le tracking unifié self.cog.issue_data[issue_iid] = { "requester_id": interaction.user.id, "last_note_id": 0, "type": "manual-report" } save_tracking(self.cog.issue_data) await interaction.edit_original_response( content=( "✅ Votre bug a été signalé avec succès ! " "Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. " f"[Voir l'issue]({result.get('web_url')})" ) ) else: await interaction.edit_original_response( content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur." ) class FeatureSuggestionModal(disnake.ui.Modal): def __init__(self, cog): self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking components = [ disnake.ui.TextInput( label="Titre de la suggestion", placeholder="Que voulez-vous ajouter ?", custom_id="suggestion_title", required=True, max_length=100, ), disnake.ui.TextInput( label="Détails de la fonctionnalité", style=disnake.TextInputStyle.paragraph, placeholder="Décrivez comment cela devrait fonctionner...", custom_id="suggestion_description", required=True, max_length=1000, ), ] super().__init__( title="Suggérer une fonctionnalité", custom_id="suggestion_modal", components=components, ) async def callback(self, interaction: disnake.ModalInteraction): await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True) 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"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n" description_text += f"**Description:**\n{description_value}" labels = ["enhancement", "manual-suggestion"] result = await gitlab_client.create_issue( title=f"[SUGGESTION] {suggestion_title}", description=description_text, labels=labels, ) if result: issue_iid = str(result.get("iid")) # ⚡ NOUVEAU: Utilise le tracking unifié self.cog.issue_data[issue_iid] = { "requester_id": interaction.user.id, "last_note_id": 0, "type": "manual-suggestion" } save_tracking(self.cog.issue_data) 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 BugReportView(disnake.ui.View): def __init__(self, cog): self.cog = cog # ⚡ NOUVEAU: Reçoit le cog 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(self.cog) await interaction.response.send_modal(modal) class FeatureSuggestionView(disnake.ui.View): def __init__(self, cog): self.cog = cog # ⚡ NOUVEAU: Reçoit le cog 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(self.cog) await interaction.response.send_modal(modal) class BugReport(commands.Cog): def __init__(self, bot): self.bot = bot self.issue_data = load_tracking() # ⚡ NOUVEAU: Charge le tracking unifié async def cog_load(self): kuby_logger.info("BugReport cog chargé. Démarrage du serveur de relais interne (port 5001)...") REPORTS_DIR = os.path.dirname(LEGACY_REPORTS_FILE) os.makedirs(REPORTS_DIR, exist_ok=True) await self._start_webhook_server() # ⚡ Enregistrement des vues persistantes (boutons panel) # Sans ça, après un redémarrage les boutons "Remplir le formulaire" # dans les anciens messages de panel ne fonctionnent plus. self.bot.add_view(BugReportView(self)) self.bot.add_view(FeatureSuggestionView(self)) kuby_logger.info("✅ Vues persistantes BugReport enregistrées (open_bug_form, open_suggestion_form)") async def _start_webhook_server(self): try: app = web.Application() app.router.add_post('/bot_event', self.handle_bot_event) self.runner = web.AppRunner(app) await self.runner.setup() self.site = web.TCPSite(self.runner, '127.0.0.1', 5001, reuse_address=True) await self.site.start() kuby_logger.info("Internal Bot Webhook Server started on 127.0.0.1:5001") except Exception as e: kuby_logger.error(f"Failed to start Internal Bot Webhook Server: {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): """Gère les webhooks GitLab (notifications instantanées)""" 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) # Récupère l'IID de l'issue issue_iid = str( payload.get("object_attributes", {}).get("iid") or payload.get("issue", {}).get("iid") ) if not issue_iid: return web.json_response({"status": "no issue id"}, status=200) # Vérifie si l'issue est trackée issue_data = self.issue_data.get(issue_iid) if not issue_data: return web.json_response({"status": "untracked issue"}, status=200) user_id = issue_data.get("requester_id") if not user_id: return web.json_response({"status": "no requester"}, status=200) user = self.bot.get_user(user_id) if not user: try: user = await self.bot.fetch_user(user_id) except: 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": # ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané) note_attr = payload.get("object_attributes", {}) is_system = note_attr.get("system", False) if not is_system: note_body = ( note_attr.get("note") or note_attr.get("body") or payload.get("note", "") or "" ) author_name = payload.get("user", {}).get("name", "Développeur") if note_body: try: embed = disnake.Embed( title="💬 Nouveau commentaire sur votre signalement", description=f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n>>> {note_body}\n\n✍️ **Par :** {author_name}", color=disnake.Color.orange() ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) await user.send(embed=embed) kuby_logger.info(f"Commentaire GitLab envoyé à {user} pour l'issue #{issue_iid}") except (disnake.Forbidden, disnake.HTTPException) as e: kuby_logger.warning(f"Impossible d'envoyer le DM à {user}: {e}") if dev and dev.id != user.id: try: await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer le commentaire à {user} pour l'issue #{issue_iid}") except: pass elif event_type == "issue": # Notifie les changements d'état/label action = payload.get("object_attributes", {}).get("action") changes = payload.get("changes", {}) state = payload.get("object_attributes", {}).get("state") 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"]] current_labels_str = ", ".join(display_labels) if display_labels else "Mis à jour" try: 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📌 **Nouveaux Labels / Statuts :** {current_labels_str}", color=disnake.Color.blue() ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) await user.send(embed=embed) except Exception as e: kuby_logger.error(f"Erreur notification label: {e}") is_closing_now = action == "close" or (state == "closed" and "state_id" in changes) if is_closing_now: try: 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🚀 **Prochaine étape :** La mise à jour arrive prochainement !\n✨ Merci pour votre aide !", color=disnake.Color.green() ) embed.set_thumbnail(url=self.bot.user.display_avatar.url) await user.send(embed=embed) except Exception as e: kuby_logger.error(f"Erreur notification clôture: {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 signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): modal = BugReportModal(self) await interaction.response.send_modal(modal) @commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité") async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): modal = FeatureSuggestionModal(self) await interaction.response.send_modal(modal) def setup(bot): bot.add_cog(BugReport(bot))