Kuby/commandes/bug_report.py
Lowei 9ddab71d83 fix(bug_report): separate exception handlers for DM blocked errors
Separate disnake.Forbidden and disnake.HTTPException handlers to properly
catch error code 50007 (DMs disabled) instead of falling through to the
outer "unexpected error" handler.
2026-05-23 15:30:01 +02:00

478 lines
No EOL
22 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):
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é ?",
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 = "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 = 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 BugReportView(disnake.ui.View):
def __init__(self):
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()
await interaction.response.send_modal(modal)
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"**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 = 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 FeatureSuggestionView(disnake.ui.View):
def __init__(self):
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()
await interaction.response.send_modal(modal)
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:
kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.")
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)."
)
except disnake.HTTPException as e:
if e.code == 50007:
kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - code 50007 (DMs peut-être désactivés).")
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} (code 50007)."
)
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 signifier_bug(self, interaction: disnake.ApplicationCommandInteraction):
"""Ouvre un formulaire pour signaler un bug."""
modal = BugReportModal()
await interaction.response.send_modal(modal)
@commands.slash_command(
name="suggerer_fonctionnalite",
description="Proposer une nouvelle fonctionnalité pour le bot",
)
async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction):
"""Ouvre un formulaire pour suggérer une fonctionnalité."""
modal = FeatureSuggestionModal()
await interaction.response.send_modal(modal)
def setup(bot):
bot.add_cog(BugReport(bot))