import disnake from disnake.ext import commands from utils.forgejo_client import gitlab_client from aiohttp import web import logging import json import os import asyncio from typing import Dict, Any, Optional from utils.premium import check_premium_tier 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 classify_webhook_event(payload: Dict[str, Any]) -> Optional[str]: """Détermine si un webhook correspond à une issue ou à un commentaire.""" object_kind = payload.get("object_kind") if object_kind in {"issue", "note"}: return object_kind action = str(payload.get("action") or payload.get("event") or "").lower() if action in {"comment_created", "comment_updated", "commented", "issue_comment_created", "issue_comment_updated"}: return "note" if "comment" in payload or payload.get("comment") or "comment" in action: return "note" if payload.get("issue") or payload.get("object_attributes") or action in {"open", "reopen", "close", "closed", "updated", "created", "edited", "label_updated", "label_added", "label_removed", "label_changed"}: return "issue" return None def extract_issue_iid(payload: Dict[str, Any]) -> Optional[str]: """Extrait l’identifiant d’issue depuis plusieurs formats Forgejo/GitLab.""" for container in (payload.get("object_attributes", {}), payload.get("issue", {}), payload.get("comment", {})): if not isinstance(container, dict): continue for key in ("iid", "number", "id"): value = container.get(key) if value not in (None, ""): return str(value) return None def extract_issue_identifier(issue_payload: Optional[Dict[str, Any]]) -> Optional[str]: """Récupère un identifiant stable à stocker dans le tracking.""" if not isinstance(issue_payload, dict): return None for key in ("iid", "number", "id"): value = issue_payload.get(key) if value not in (None, ""): return str(value) return None def normalize_labels(labels: Any) -> list[str]: """Normalise une liste de labels provenant de Forgejo/GitLab.""" if not labels: return [] if isinstance(labels, str): return [labels] if isinstance(labels, list): normalized = [] for item in labels: if isinstance(item, dict): name = item.get("name") or item.get("title") or item.get("label") or item.get("id") else: name = item if name is None: continue name = str(name).strip() if name and name not in normalized: normalized.append(name) return normalized return [] def extract_label_change(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Extrait le changement de labels d’un payload d’issue.""" action = str(payload.get("action") or payload.get("event") or "").lower() if not action.startswith("label"): return None issue = payload.get("issue") or {} current_labels = normalize_labels(issue.get("labels") or payload.get("labels")) if not current_labels: return None label_payload = payload.get("label") label_name = None if isinstance(label_payload, dict): label_name = label_payload.get("name") or label_payload.get("title") or label_payload.get("label") elif isinstance(label_payload, str): label_name = label_payload previous_labels = [] if isinstance(payload.get("changes"), dict): prev = payload["changes"].get("labels") or payload["changes"].get("label") if isinstance(prev, dict): previous_labels = normalize_labels(prev.get("previous")) if not previous_labels and issue.get("labels"): previous_labels = [] if action in {"label_removed", "label_deleted"}: return { "previous": previous_labels or ([label_name] if label_name else []), "current": current_labels, "added": [], "removed": [label_name] if label_name else [], } added = [label for label in current_labels if label not in previous_labels] removed = [label for label in previous_labels if label not in current_labels] if not added and not removed and label_name: added = [label_name] return { "previous": previous_labels, "current": current_labels, "added": added, "removed": removed, } def calculate_progress_from_labels(labels: list[str]) -> float: """Estime une progression visuelle à partir des labels présents.""" if not labels: return 0.1 normalized = [label.lower() for label in labels] if any(token in normalized for token in ["done", "resolved", "fixed", "closed", "terminé", "résolu", "ok", "completed"]): return 1.0 if any(token in normalized for token in ["review", "ready", "qa", "testing", "test", "validated", "validation", "verif", "vérif"]): return 0.8 if any(token in normalized for token in ["in progress", "working", "wip", "develop", "dev", "investigating", "progress", "travail"]): return 0.6 if any(token in normalized for token in ["confirmed", "accepted", "triaged", "priority", "urgent", "prioritaire"]): return 0.4 if any(token in normalized for token in ["bug", "enhancement", "feature", "manual", "new", "suggestion"]): return 0.2 return 0.3 def build_status_message_from_labels(labels: list[str]) -> str: """Traduit les labels en un message utilisateur lisible.""" if not labels: return "⏳ La demande évolue et l'équipe suit son avancement." normalized = [str(label).lower() for label in labels if str(label).strip()] if any(token in normalized for token in ["done", "resolved", "fixed", "closed", "terminé", "résolu", "completed", "ok"]): return "✅ La demande a été traitée et est prête à être déployée." if any(token in normalized for token in ["review", "qa", "testing", "test", "validated", "validation", "verif", "vérif"]): return "🔎 La demande est en cours de vérification par l'équipe." if any(token in normalized for token in ["in progress", "working", "wip", "develop", "dev", "investigating", "progress", "travail"]): return "🛠️ L'équipe est en train de travailler dessus." if any(token in normalized for token in ["confirmed", "accepted", "triaged", "priority", "urgent", "prioritaire"]): return "🗂️ La demande a bien été prise en compte et est en cours d'analyse." if any(token in normalized for token in ["bug", "enhancement", "feature", "manual", "new", "suggestion"]): return "📝 La demande a été reçue et sera étudiée prochainement." return "⏳ La demande évolue et l'équipe suit son avancement." def build_progress_bar(progress: float, width: int = 18) -> str: """Construit une barre de progression stylée en Unicode.""" progress = max(0.0, min(1.0, progress)) filled = int(round(progress * width)) filled = max(0, min(width, filled)) empty = width - filled percent = int(progress * 100) return f"{'▰' * filled}{'▱' * empty} {percent}%" def color_from_progress(progress: float) -> disnake.Color: """Retourne une couleur violet→bleu selon la progression.""" progress = max(0.0, min(1.0, progress)) purple = (117, 82, 255) blue = (74, 144, 255) r = int(purple[0] + (blue[0] - purple[0]) * progress) g = int(purple[1] + (blue[1] - purple[1]) * progress) b = int(purple[2] + (blue[2] - purple[2]) * progress) return disnake.Color.from_rgb(r, g, b) 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 = extract_issue_identifier(result) if not issue_iid: kuby_logger.warning("Impossible de déterminer l’ID de l’issue créée depuis le retour Forgejo/GitLab") await interaction.edit_original_response(content="✅ Votre rapport a été envoyé, mais l’identifiant de l’issue n’a pas pu être récupéré automatiquement.") return 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') or result.get('html_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 = extract_issue_identifier(result) if not issue_iid: kuby_logger.warning("Impossible de déterminer l’ID de l’issue créée depuis le retour Forgejo/GitLab") await interaction.edit_original_response(content="✅ Votre suggestion a été envoyée, mais l’identifiant de l’issue n’a pas pu être récupéré automatiquement.") return 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') or result.get('html_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) app.router.add_post('/deployment_complete', self.handle_deployment_complete) 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_deployment_complete(self, request): """Notifie les demandeurs dont la fonctionnalité a été intégrée et attend le prochain déploiement.""" try: await request.json() for issue_iid, issue_data in list(self.issue_data.items()): if not issue_data.get("pending_deployment_notification"): continue requester_id = issue_data.get("requester_id") if not requester_id: continue user = self.bot.get_user(requester_id) if not user: try: user = await self.bot.fetch_user(requester_id) except Exception: continue try: embed = disnake.Embed( title="🚀 Fonctionnalité disponible !", description=( "✅ Votre demande est désormais fonctionnelle sur le bot.\n\n" "Merci encore pour votre contribution et votre patience." ), color=disnake.Color.blurple() ) if self.bot.user.display_avatar: embed.set_thumbnail(url=self.bot.user.display_avatar.url) await user.send(embed=embed) except Exception as e: kuby_logger.warning(f"Impossible d'envoyer la notification de déploiement à {user}: {e}") issue_data["pending_deployment_notification"] = False save_tracking(self.issue_data) return web.json_response({"status": "notified"}, status=200) except Exception as e: kuby_logger.error(f"Error handling deployment complete: {e}") return web.json_response({"status": "error", "message": str(e)}, status=500) async def handle_bot_event(self, request): """Gère les webhooks (adapté pour GitLab ET Forgejo)""" 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.") payload = await request.json() event_type = classify_webhook_event(payload) if event_type not in ["issue", "note"]: kuby_logger.info( "Webhook ignoré : type d’événement non reconnu (keys=%s action=%s object_kind=%s)", list(payload.keys()), payload.get("action") or payload.get("event"), payload.get("object_kind"), ) return web.json_response({"status": "ignored"}, status=200) # Récupère l'IID de l'issue issue_iid = extract_issue_iid(payload) if not issue_iid: kuby_logger.warning("Webhook ignoré : aucun identifiant d’issue trouvé dans le payload") return web.json_response({"status": "no issue id"}, status=200) 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) # 🕵️ 3. RÉCUPÉRATION DU TITRE issue_title = ( payload.get("object_attributes", {}).get("title") or payload.get("issue", {}).get("title") or payload.get("issue", {}).get("name") or f"#{issue_iid}" ) if event_type == "note": # ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané) comment_payload = payload.get("comment") if isinstance(comment_payload, dict): note_body = comment_payload.get("body") or comment_payload.get("content") or "" author_name = ( payload.get("sender", {}).get("username") or payload.get("sender", {}).get("login") or payload.get("user", {}).get("name") or "Développeur" ) is_system = False else: note_attr = payload.get("object_attributes", {}) is_system = note_attr.get("system", False) 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 not is_system and 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 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": action = payload.get("object_attributes", {}).get("action") or payload.get("action") state = payload.get("object_attributes", {}).get("state") or payload.get("issue", {}).get("state") label_change = extract_label_change(payload) if label_change: current_labels = label_change["current"] progress = calculate_progress_from_labels(current_labels) bar = build_progress_bar(progress) status_message = build_status_message_from_labels(current_labels) try: embed = disnake.Embed( title="📈 Évolution du ticket", description=f"Le suivi de **{issue_title}** a été mis à jour.\n\n{status_message}", color=color_from_progress(progress), ) embed.add_field(name="Progression", value=bar, inline=False) embed.set_thumbnail(url=self.bot.user.display_avatar.url) await user.send(embed=embed) kuby_logger.info(f"Notification de progression envoyée à {user} pour l'issue #{issue_iid}") except Exception as e: kuby_logger.error(f"Erreur notification progression: {e}") is_closing_now = action in ["close", "closed"] or state == "closed" if is_closing_now: self.issue_data[issue_iid]["pending_deployment_notification"] = True save_tracking(self.issue_data) try: embed = disnake.Embed( title="🛠️ Demande traitée !", description=( f"Bonne nouvelle ! Votre demande **{issue_title}** a été intégrée au code du bot.\n\n" "✅ Elle n'est pas encore disponible au public, mais elle est maintenant prise en charge côté développement.\n\n" "🔜 Lors du prochain déploiement, Kuby vous informera qu'elle est désormais fonctionnelle." ), color=disnake.Color.green() ) if self.bot.user.display_avatar: 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") @check_premium_tier() 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é") @check_premium_tier() 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))