Kuby/commandes/bug_report.py

454 lines
20 KiB
Python
Raw Normal View History

2026-05-06 17:07:09 +02:00
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")
2026-05-19 18:50:16 +02:00
def save_report(issue_iid, user_id):
os.makedirs(os.path.dirname(REPORTS_FILE), exist_ok=True)
try:
2026-05-01 16:16:33 +02:00
data = {}
if os.path.exists(REPORTS_FILE):
2026-05-01 16:16:33 +02:00
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.")
2026-05-19 18:50:16 +02:00
data[str(issue_iid)] = user_id
2026-05-19 18:50:16 +02:00
2026-05-01 16:16:33 +02:00
temp_file = f"{REPORTS_FILE}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
2026-05-01 16:16:33 +02:00
os.replace(temp_file, REPORTS_FILE)
except Exception as e:
kuby_logger.error(f"Error saving report to JSON: {e}")
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
class BugReportModal(disnake.ui.Modal):
def __init__(self, priority_choice):
super().__init__(title="Signaler un Bug", components=[self.bug_title, self.description])
2026-05-19 18:50:16 +02:00
# 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
2026-05-06 17:07:09 +02:00
bug_title = disnake.ui.TextInput(
label="Titre du bug",
placeholder="Décrivez brièvement le problème...",
required=True,
2026-05-19 18:50:16 +02:00
max_length=100,
)
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
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,
2026-05-19 18:50:16 +02:00
max_length=1000,
)
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.Interaction):
await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True)
2026-05-19 18:50:16 +02:00
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"
2026-05-19 18:50:16 +02:00
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(
2026-05-19 18:50:16 +02:00
title=f"[MANUAL] {bug_title}",
description=description_text,
2026-05-19 18:50:16 +02:00
labels=labels,
)
2026-05-19 18:50:16 +02:00
if result:
issue_iid = result.get("iid")
save_report(issue_iid, interaction.user.id)
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
await interaction.edit_original_response(
content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur."
)
2026-05-06 17:07:09 +02:00
class FeatureSuggestionModal(disnake.ui.Modal):
def __init__(self):
super().__init__(title="Suggérer une fonctionnalité", components=[self.suggestion_title, self.description])
2026-05-06 17:07:09 +02:00
suggestion_title = disnake.ui.TextInput(
label="Titre de la suggestion",
placeholder="Que voulez-vous ajouter ?",
required=True,
2026-05-19 18:50:16 +02:00
max_length=100,
)
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
description = disnake.ui.TextInput(
label="Détails de la fonctionnalité",
style=disnake.TextInputStyle.paragraph,
placeholder="Décrivez comment cela devrait fonctionner...",
required=True,
2026-05-19 18:50:16 +02:00
max_length=1000,
)
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.Interaction):
await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True)
2026-05-19 18:50:16 +02:00
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"
2026-05-19 18:50:16 +02:00
description_text += f"**Description:**\n{description_value}"
labels = ["enhancement", "manual-suggestion"]
2026-05-19 18:50:16 +02:00
result = await gitlab_client.create_issue(
2026-05-19 18:50:16 +02:00
title=f"[SUGGESTION] {suggestion_title}",
description=description_text,
2026-05-19 18:50:16 +02:00
labels=labels,
)
2026-05-19 18:50:16 +02:00
if result:
issue_iid = result.get("iid")
save_report(issue_iid, interaction.user.id)
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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()
2026-05-19 18:50:16 +02:00
max_retries = 5
retry_delay = 2
2026-05-19 18:50:16 +02:00
for attempt in range(1, max_retries + 1):
try:
self.site = web.TCPSite(self.runner, '127.0.0.1', 5001)
await self.site.start()
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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}")
2026-05-19 18:50:16 +02:00
self.bot.loop.create_task(cleanup())
async def handle_bot_event(self, request):
2026-05-01 16:16:33 +02:00
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()
2026-05-19 18:50:16 +02:00
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)
2026-05-01 16:16:33 +02:00
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", {})
2026-05-19 18:50:16 +02:00
if "labels" in changes:
curr_labels = [l.get("title") for l in changes["labels"].get("current", []) if l.get("title")]
2026-05-19 18:50:16 +02:00
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 !",
2026-05-19 18:50:16 +02:00
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
),
disnake.ui.Separator(divider=True),
2026-05-19 18:50:16 +02:00
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}"
),
)
]
2026-05-19 18:50:16 +02:00
2026-03-15 15:48:28 +01:00
try:
await user.send(components=components)
2026-05-19 18:50:16 +02:00
kuby_logger.info(
f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}"
)
2026-03-15 15:48:28 +01:00
if dev and dev.id != user.id:
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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}")
2026-05-01 16:16:33 +02:00
except Exception as e:
kuby_logger.error(f"Erreur lors de l'envoi du MP de statut à {user}: {e}")
except Exception as e:
2026-05-01 16:16:33 +02:00
kuby_logger.error(f"Erreur inattendue dans la notification de label : {e}")
2026-05-01 16:16:33 +02:00
state = payload.get("object_attributes", {}).get("state")
is_closing_now = action == "close" or (state == "closed" and "state_id" in changes)
2026-05-19 18:50:16 +02:00
2026-05-01 16:16:33 +02:00
if is_closing_now:
try:
components = [
disnake.ui.Container(
disnake.ui.Section(
"🛠️ Bug / Suggestion Terminé(e) !",
2026-05-19 18:50:16 +02:00
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
),
disnake.ui.Separator(divider=True),
2026-05-19 18:50:16 +02:00
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 !"),
)
]
2026-05-19 18:50:16 +02:00
2026-03-15 15:48:28 +01:00
try:
await user.send(components=components)
2026-05-19 18:50:16 +02:00
kuby_logger.info(
f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}"
)
2026-03-15 15:48:28 +01:00
if dev and dev.id != user.id:
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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:
2026-05-19 18:50:16 +02:00
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}")
2026-05-01 16:16:33 +02:00
except Exception as e:
kuby_logger.error(f"Erreur lors de l'envoi du MP de clôture à {user}: {e}")
except Exception as e:
2026-05-01 16:16:33 +02:00
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)
2026-05-19 18:50:16 +02:00
if not is_system:
note_body = note_attr.get("note", "")
author_name = payload.get("user", {}).get("name", "Développeur")
2026-05-19 18:50:16 +02:00
try:
components = [
disnake.ui.Container(
disnake.ui.Section(
"💬 Nouveau commentaire sur votre signalement",
2026-05-19 18:50:16 +02:00
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
),
disnake.ui.Separator(divider=True),
2026-05-19 18:50:16 +02:00
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),
2026-05-19 18:50:16 +02:00
disnake.ui.TextDisplay(f"✍️ **Par :** {author_name}"),
)
]
2026-05-19 18:50:16 +02:00
2026-03-15 15:48:28 +01:00
try:
await user.send(components=components)
2026-05-19 18:50:16 +02:00
kuby_logger.info(
f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}"
)
2026-03-15 15:48:28 +01:00
if dev and dev.id != user.id:
2026-05-19 18:50:16 +02:00
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:
2026-05-01 16:16:33 +02:00
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)
2026-05-19 18:50:16 +02:00
@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"},
),
):
2026-05-06 17:07:09 +02:00
# Simulate app_commands.Choice behavior for existing code
from collections import namedtuple
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
Choice = namedtuple('Choice', ['name', 'value'])
2026-05-19 18:50:16 +02:00
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")
2026-05-06 17:07:09 +02:00
choice = Choice(name=p_name, value=priority)
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
await interaction.response.send_modal(BugReportModal(choice))
2026-05-19 18:50:16 +02:00
@commands.slash_command(
name="suggerer_fonctionnalite",
description="Proposer une nouvelle fonctionnalité pour le bot",
)
2026-05-06 17:07:09 +02:00
async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction):
await interaction.response.send_modal(FeatureSuggestionModal())
2026-05-19 18:50:16 +02:00
def setup(bot):
bot.add_cog(BugReport(bot))
2026-05-19 18:50:16 +02:00