Kuby/commandes/bug_report.py
Lowei 3534363f98 fix(bot): améliorer la robustesse des handlers d'erreurs Discord
- advanced_logger.py: parsing NDJSON plus robuste pour les fichiers JSON
  corrompus ou malformés lors de la vérification des rôles précédents.
  Évite l'erreur "Expecting value: line 2 column 1".

- bot.py: simplification de la détection d'erreur 403 Missing Permissions
  (codes 50013 et 20023) lors de la restauration des rôles au join d'un membre.
  Évite le log d'erreur "Failed to restore safe roles" quand c'est juste un
  problème de permissions/bot role hierarchy.

- bug_report.py: simplification de la détection du code 50007 (Cannot send
  messages to this user) lors de l'envoi de notifications de labels GitLab.
  Évite le log "Erreur inattendue dans la notification de label".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 18:46:32 +02:00

475 lines
No EOL
21 KiB
Python

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):
if priority_choice is None:
self.priority_choice = type(
"PriorityChoice",
(),
{"name": "Normal", "value": "Normal"},
)()
else:
self.priority_choice = priority_choice
super().__init__(
title="Signaler un Bug",
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é ? Comment reproduire le bug ?",
custom_id="bug_description",
required=True,
max_length=1000,
)
]
)
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 = getattr(self.priority_choice, "name", None) or "Normal"
priority_value = getattr(self.priority_choice, "value", None) or "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 = result.get("iid")
save_report(issue_iid, interaction.user.id)
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):
super().__init__(
title="Suggérer une fonctionnalité",
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,
)
]
)
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"**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):
# 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 = 10
retry_delay = 5
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 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})"
)
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)
# 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}")
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:
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(embed=embed)
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:
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: {error_code})"
)
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:
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(embed=embed)
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)
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:
note_body = (
note_attr.get("note")
or note_attr.get("body")
or payload.get("note", "")
or payload.get("object_attributes", {}).get("note", "")
or ""
)
author_name = payload.get("user", {}).get("name", "Développeur")
try:
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(embed=embed)
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:
try:
await dev.send(
f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)."
)
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}",
exc_info=True,
)
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"},
),
):
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()}
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))