- 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>
475 lines
No EOL
21 KiB
Python
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)) |