Merge branch 'main' into Dev-Eliott
This commit is contained in:
commit
ad8c43dddc
10 changed files with 1070 additions and 285 deletions
9
TODO.md
9
TODO.md
|
|
@ -1,9 +0,0 @@
|
|||
# TODO - Panel sanctions (components V2)
|
||||
|
||||
- [ ] Revoir `commandes/moderation.py` : ajouter `panel_channel_id` dans la table `mod_config`
|
||||
- [ ] Mettre à jour `/modconfig` avec un nouveau bouton/sélecteur “Salon sanctions” (ChannelSelect) (style components V2, callbacks dédiés)
|
||||
- [ ] Ajouter un formatter “panneau sanctions” (liste bullets + citations/raison + date + modérateur + durée si TIMEOUT)
|
||||
- [ ] Ajouter une fonction `send_sanction_panel(...)` et l’appeler dans `warn`, `timeout`, `kick`, `ban` (push automatique)
|
||||
- [ ] Enrichir `/modpanel` pour afficher les sanctions actives récentes “proprement” (et garder l’UI actuelle)
|
||||
- [ ] Nettoyer les callbacks (supprimer les `lambda` dans `ModConfigView` si présents)
|
||||
- [ ] Tester localement : `/modconfig` -> config channel, puis exécuter des sanctions et vérifier l’affichage dans le salon
|
||||
99
bot.py
99
bot.py
|
|
@ -13,7 +13,6 @@ import asyncio
|
|||
from src.logger import kuby_logger, log_performance
|
||||
import logging
|
||||
from src.advanced_logger import AdvancedLogger
|
||||
import subprocess
|
||||
|
||||
SENSITIVE_PERMISSIONS = {
|
||||
"administrator", "manage_guild", "manage_roles", "manage_channels",
|
||||
|
|
@ -30,7 +29,6 @@ class RoleApprovalView(disnake.ui.View):
|
|||
|
||||
@disnake.ui.button(label="Accepter", style=disnake.ButtonStyle.green, emoji="✅")
|
||||
async def accept(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
# Check if member still in guild
|
||||
guild = self.member.guild
|
||||
current_member = guild.get_member(self.member.id)
|
||||
if not current_member:
|
||||
|
|
@ -42,7 +40,6 @@ class RoleApprovalView(disnake.ui.View):
|
|||
await current_member.add_roles(*self.roles, reason="Restauration sécurisée approuvée par le propriétaire")
|
||||
await interaction.response.send_message(f"✅ Rôles sensibles restaurés pour {self.member.mention}.", ephemeral=True)
|
||||
|
||||
# Update original message
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(f"✅ Vous avez approuvé la restauration des rôles pour {self.member.mention}."),
|
||||
|
|
@ -58,7 +55,6 @@ class RoleApprovalView(disnake.ui.View):
|
|||
async def deny(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
await interaction.response.send_message(f"❌ Vous avez refusé la restauration pour {self.member.mention}.", ephemeral=True)
|
||||
|
||||
# Update original message
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(f"❌ Vous avez refusé la restauration des rôles pour {self.member.mention}."),
|
||||
|
|
@ -68,6 +64,7 @@ class RoleApprovalView(disnake.ui.View):
|
|||
await interaction.edit_original_response(embed=None, components=components, view=None)
|
||||
self.stop()
|
||||
|
||||
|
||||
# Récupération et validation de l'ID d'application
|
||||
application_id_raw = os.getenv("APPLICATION_ID")
|
||||
kuby_logger.debug(f"Raw APPLICATION_ID: {application_id_raw}")
|
||||
|
|
@ -85,6 +82,7 @@ intents.members = True
|
|||
intents.voice_states = True
|
||||
kuby_logger.debug(f"Disnake intents configured: {intents}")
|
||||
|
||||
|
||||
class MyBot(commands.Bot):
|
||||
def __init__(self):
|
||||
kuby_logger.debug("Initializing MyBot class")
|
||||
|
|
@ -100,29 +98,11 @@ class MyBot(commands.Bot):
|
|||
self.advanced_logger = AdvancedLogger(self)
|
||||
kuby_logger.info("✅ Advanced logger initialized")
|
||||
|
||||
# Start the Flask Webhook Server
|
||||
self.flask_process = None
|
||||
try:
|
||||
flask_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "serveur_flask.py")
|
||||
if os.path.exists(flask_path):
|
||||
self.flask_process = subprocess.Popen(
|
||||
[sys.executable, flask_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
cwd=os.path.dirname(flask_path)
|
||||
)
|
||||
kuby_logger.info(f"✅ Serveur Flask démarré (PID: {self.flask_process.pid})")
|
||||
else:
|
||||
kuby_logger.warning(f"⚠️ Fichier serveur_flask.py introuvable")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ Impossible de démarrer le serveur Flask: {e}")
|
||||
|
||||
# Flask est géré par PM2 (kuby-flask), pas besoin de le lancer ici
|
||||
|
||||
async def do_async_setup(self):
|
||||
# --- INITIALIZATION LOGIC (Called before start() in kuby.py) ---
|
||||
kuby_logger.info("🚀 Configuration initiale du bot Kuby...")
|
||||
|
||||
# Charger les extensions
|
||||
extensions = [
|
||||
"commandes.whitelist",
|
||||
"commandes.whitelist_monitor",
|
||||
|
|
@ -154,31 +134,6 @@ class MyBot(commands.Bot):
|
|||
except Exception as e:
|
||||
kuby_logger.error(f"❌ Erreur lors du chargement de {extension} : {e}", exc_info=True)
|
||||
|
||||
# --- SYSTÈME DE WHITELIST (DÉDIÉ SLASH COMMANDS) ---
|
||||
async def global_slash_whitelist_check(inter: disnake.ApplicationCommandInteraction):
|
||||
try:
|
||||
if await self.is_owner(inter.author):
|
||||
return True
|
||||
command_name = inter.data.name
|
||||
if command_name in ["ticketconfig", "ticketpanel", "ticket_config", "ticket_panel", "ticket_whitelist_config", "invitations"]:
|
||||
return True
|
||||
whitelist_cog = self.get_cog("WhitelistMonitor")
|
||||
if not whitelist_cog:
|
||||
return True
|
||||
is_whitelisted = whitelist_cog.is_whitelisted(inter.guild.id, inter.author.id)
|
||||
if not is_whitelisted:
|
||||
kuby_logger.info(f"🚫 [Check] Accès refusé : {inter.author} sur /{command_name}")
|
||||
msg = "❌ Vous n'êtes pas autorisé à utiliser cette commande. Veuillez demander à être ajouté à la whitelist."
|
||||
if not inter.response.is_done():
|
||||
await inter.response.send_message(msg, ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"⚠️ [Check] Erreur critique: {e}", exc_info=True)
|
||||
return True
|
||||
|
||||
# self.add_app_command_check(global_slash_whitelist_check)
|
||||
|
||||
# --- LOGGING DES INTERACTIONS ---
|
||||
@self.listen("on_interaction")
|
||||
async def log_interaction(inter: disnake.Interaction):
|
||||
|
|
@ -187,17 +142,6 @@ class MyBot(commands.Bot):
|
|||
|
||||
kuby_logger.info("✅ Configuration terminée")
|
||||
|
||||
async def close(self):
|
||||
if hasattr(self, 'flask_process') and self.flask_process:
|
||||
kuby_logger.info("Arrêt du serveur Flask interne...")
|
||||
self.flask_process.terminate()
|
||||
try:
|
||||
self.flask_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.flask_process.kill()
|
||||
await super().close()
|
||||
|
||||
# --- ÉVÉNEMENTS RESTAURÉS ---
|
||||
async def on_ready(self):
|
||||
kuby_logger.info(f"🤖 Bot connecté : {self.user} (ID : {self.user.id})")
|
||||
kuby_logger.info(f"📊 Serveurs : {len(self.guilds)}")
|
||||
|
|
@ -224,23 +168,16 @@ class MyBot(commands.Bot):
|
|||
roles_to_add = [guild.get_role(rid) for rid in previous_roles.get("role_ids", [])]
|
||||
roles_to_add = [r for r in roles_to_add if r and r < guild.me.top_role]
|
||||
if roles_to_add:
|
||||
# Separate roles into safe and sensitive
|
||||
safe_roles = []
|
||||
sensitive_roles = []
|
||||
|
||||
for role in roles_to_add:
|
||||
is_sensitive = False
|
||||
for perm in SENSITIVE_PERMISSIONS:
|
||||
if getattr(role.permissions, perm, False):
|
||||
is_sensitive = True
|
||||
break
|
||||
|
||||
is_sensitive = any(getattr(role.permissions, perm, False) for perm in SENSITIVE_PERMISSIONS)
|
||||
if is_sensitive:
|
||||
sensitive_roles.append(role)
|
||||
else:
|
||||
safe_roles.append(role)
|
||||
|
||||
# Restore safe roles automatically
|
||||
if safe_roles:
|
||||
retries = 3
|
||||
for i in range(retries):
|
||||
|
|
@ -250,8 +187,15 @@ class MyBot(commands.Bot):
|
|||
break
|
||||
except (disnake.HTTPException, disnake.GatewayNotFound, asyncio.TimeoutError) as e:
|
||||
is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504]
|
||||
if not isinstance(e, disnake.HTTPException): is_transient = True
|
||||
|
||||
if not isinstance(e, disnake.HTTPException):
|
||||
is_transient = True
|
||||
# 403 with code 50013 = Missing Permissions - don't retry
|
||||
if isinstance(e, disnake.HTTPException) and e.status == 403:
|
||||
error_code = getattr(e, 'code', None)
|
||||
# 50013 = Missing Permissions, 20023 = Cannot modify a role higher than bot's highest role
|
||||
if error_code in (50013, 20023):
|
||||
kuby_logger.warning(f"Missing Manage Roles permission to restore safe roles for {member} in {member.guild.name} - please ensure the bot has the Manage Roles permission")
|
||||
break
|
||||
if is_transient and i < retries - 1:
|
||||
await asyncio.sleep((i + 1) * 2)
|
||||
continue
|
||||
|
|
@ -261,7 +205,6 @@ class MyBot(commands.Bot):
|
|||
kuby_logger.error(f"Error restoring safe roles for {member}: {e}")
|
||||
break
|
||||
|
||||
# Handle sensitive roles with owner approval
|
||||
if sensitive_roles:
|
||||
try:
|
||||
owner = guild.owner or await guild.fetch_member(guild.owner_id)
|
||||
|
|
@ -298,19 +241,23 @@ class MyBot(commands.Bot):
|
|||
kuby_logger.error(f"Error in leave notification: {e}")
|
||||
|
||||
async def on_message_delete(self, message):
|
||||
if message.author.bot: return
|
||||
if message.author.bot:
|
||||
return
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_message_event(message, "deleted")
|
||||
|
||||
async def on_message_edit(self, before, after):
|
||||
if before.author.bot or before.content == after.content: return
|
||||
if before.author.bot or before.content == after.content:
|
||||
return
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_message_event(before, "edited", {
|
||||
"before_content": before.content, "after_content": after.content
|
||||
"before_content": before.content,
|
||||
"after_content": after.content
|
||||
})
|
||||
|
||||
async def on_voice_state_update(self, member, before, after):
|
||||
if member.bot: return
|
||||
if member.bot:
|
||||
return
|
||||
if before.channel != after.channel and hasattr(self, 'advanced_logger'):
|
||||
if before.channel is None:
|
||||
self.advanced_logger.log_voice_event(member, after.channel, "joined")
|
||||
|
|
@ -322,9 +269,7 @@ class MyBot(commands.Bot):
|
|||
|
||||
async def on_command_error(self, ctx, error):
|
||||
if isinstance(error, commands.CommandNotFound):
|
||||
return # Ignorer les erreurs "Command not found" pour éviter de polluer les logs (ex: !!)
|
||||
return
|
||||
if isinstance(error, commands.CheckFailure):
|
||||
return
|
||||
kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True)
|
||||
|
||||
bot = MyBot()
|
||||
|
|
@ -35,16 +35,7 @@ def save_report(issue_iid, user_id):
|
|||
|
||||
|
||||
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
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
title="Signaler un Bug",
|
||||
components=[
|
||||
|
|
@ -58,11 +49,11 @@ class BugReportModal(disnake.ui.Modal):
|
|||
disnake.ui.TextInput(
|
||||
label="Description détaillée",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Que s'est-il passé ? Comment reproduire le bug ?",
|
||||
placeholder="Que s'est-il passé ?",
|
||||
custom_id="bug_description",
|
||||
required=True,
|
||||
max_length=1000,
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -73,8 +64,8 @@ class BugReportModal(disnake.ui.Modal):
|
|||
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"
|
||||
priority_name = "Normale"
|
||||
priority_value = "Normal"
|
||||
|
||||
description_value = interaction.text_values.get("bug_description") or ""
|
||||
|
||||
|
|
@ -96,7 +87,7 @@ class BugReportModal(disnake.ui.Modal):
|
|||
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. "
|
||||
"Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. "
|
||||
f"[Voir l'issue]({result.get('web_url')})"
|
||||
)
|
||||
)
|
||||
|
|
@ -106,6 +97,16 @@ class BugReportModal(disnake.ui.Modal):
|
|||
)
|
||||
|
||||
|
||||
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__(
|
||||
|
|
@ -135,7 +136,7 @@ class FeatureSuggestionModal(disnake.ui.Modal):
|
|||
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"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n"
|
||||
description_text += f"**Description:**\n{description_value}"
|
||||
|
||||
labels = ["enhancement", "manual-suggestion"]
|
||||
|
|
@ -162,18 +163,34 @@ class FeatureSuggestionModal(disnake.ui.Modal):
|
|||
)
|
||||
|
||||
|
||||
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 = 5
|
||||
retry_delay = 2
|
||||
max_retries = 10
|
||||
retry_delay = 5
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
|
|
@ -184,7 +201,7 @@ class BugReport(commands.Cog):
|
|||
)
|
||||
return
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
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})"
|
||||
|
|
@ -259,6 +276,19 @@ class BugReport(commands.Cog):
|
|||
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}")
|
||||
|
|
@ -283,24 +313,18 @@ class BugReport(commands.Cog):
|
|||
current_labels_str = "Mis à jour"
|
||||
|
||||
try:
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
"🛠️ Mise à jour de votre signalement !",
|
||||
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
|
||||
),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(
|
||||
f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe."
|
||||
),
|
||||
disnake.ui.TextDisplay(
|
||||
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(components=components)
|
||||
await user.send(embed=embed)
|
||||
kuby_logger.info(
|
||||
f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}"
|
||||
)
|
||||
|
|
@ -310,11 +334,10 @@ class BugReport(commands.Cog):
|
|||
f"**{current_labels_str}** pour l'issue #{issue_iid} ({issue_title})."
|
||||
)
|
||||
except (disnake.Forbidden, disnake.HTTPException) as e:
|
||||
if isinstance(e, disnake.Forbidden) or (
|
||||
isinstance(e, disnake.HTTPException) and e.code == 50007
|
||||
):
|
||||
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: {getattr(e, 'code', 'Unknown')})"
|
||||
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(
|
||||
|
|
@ -332,25 +355,19 @@ class BugReport(commands.Cog):
|
|||
|
||||
if is_closing_now:
|
||||
try:
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
"🛠️ Bug / Suggestion Terminé(e) !",
|
||||
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
|
||||
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 !"
|
||||
),
|
||||
disnake.ui.Separator(divider=True),
|
||||
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 !"),
|
||||
color=disnake.Color.green()
|
||||
)
|
||||
]
|
||||
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
||||
|
||||
try:
|
||||
await user.send(components=components)
|
||||
await user.send(embed=embed)
|
||||
kuby_logger.info(
|
||||
f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}"
|
||||
)
|
||||
|
|
@ -378,8 +395,12 @@ class BugReport(commands.Cog):
|
|||
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:
|
||||
# GitLab peut envoyer des champs différents selon version/config
|
||||
note_body = (
|
||||
note_attr.get("note")
|
||||
or note_attr.get("body")
|
||||
|
|
@ -389,26 +410,20 @@ class BugReport(commands.Cog):
|
|||
)
|
||||
author_name = payload.get("user", {}).get("name", "Développeur")
|
||||
|
||||
|
||||
try:
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
"💬 Nouveau commentaire sur votre signalement",
|
||||
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url),
|
||||
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}"
|
||||
),
|
||||
disnake.ui.Separator(divider=True),
|
||||
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),
|
||||
disnake.ui.TextDisplay(f"✍️ **Par :** {author_name}"),
|
||||
color=disnake.Color.orange()
|
||||
)
|
||||
]
|
||||
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
||||
|
||||
try:
|
||||
await user.send(components=components)
|
||||
await user.send(embed=embed)
|
||||
kuby_logger.info(
|
||||
f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}"
|
||||
)
|
||||
|
|
@ -425,7 +440,10 @@ class BugReport(commands.Cog):
|
|||
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}")
|
||||
kuby_logger.error(
|
||||
f"Erreur inattendue dans la notification de commentaire : {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return web.json_response({"status": "success"}, status=200)
|
||||
|
||||
|
|
@ -437,35 +455,20 @@ class BugReport(commands.Cog):
|
|||
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"},
|
||||
),
|
||||
):
|
||||
# Simulate app_commands.Choice behavior for existing code
|
||||
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()}
|
||||
|
||||
# priority vient du value GitLab (Low/Normal/High/Urgent)
|
||||
p_name = reverse.get(priority, "Normal")
|
||||
choice = Choice(name=p_name, value=priority)
|
||||
|
||||
await interaction.response.send_modal(BugReportModal(choice))
|
||||
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):
|
||||
await interaction.response.send_modal(FeatureSuggestionModal())
|
||||
"""Ouvre un formulaire pour suggérer une fonctionnalité."""
|
||||
modal = FeatureSuggestionModal()
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(BugReport(bot))
|
||||
|
||||
|
|
|
|||
221
commandes/gitlab_integration.py
Normal file
221
commandes/gitlab_integration.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import aiohttp
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
|
||||
# Environment variables for GitLab access
|
||||
GITLAB_TOKEN = os.getenv("GITLAB_TOKEN")
|
||||
GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path
|
||||
|
||||
if not GITLAB_TOKEN or not GITLAB_PROJECT_ID:
|
||||
raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID")
|
||||
|
||||
# File to persist issue tracking information
|
||||
ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json")
|
||||
|
||||
def load_issue_tracking() -> Dict[str, Any]:
|
||||
if os.path.exists(ISSUE_TRACK_FILE):
|
||||
with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_issue_tracking(data: Dict[str, Any]) -> None:
|
||||
with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
class GitLabIntegration(commands.Cog):
|
||||
"""Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.issue_data: Dict[str, Any] = load_issue_tracking() # key: issue_iid (str) -> {"requester_id": int, "last_note_id": int}
|
||||
self.check_new_notes.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.check_new_notes.cancel()
|
||||
|
||||
async def _create_gitlab_issue(self, title: str, description: str, labels: List[str]) -> Dict[str, Any]:
|
||||
"""Create an issue on GitLab and return the JSON response."""
|
||||
url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues"
|
||||
headers = {
|
||||
"PRIVATE-TOKEN": GITLAB_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"labels": ",".join(labels),
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]:
|
||||
"""Fetch all notes (comments) for a given issue."""
|
||||
url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes"
|
||||
headers = {"PRIVATE-TOKEN": GITLAB_TOKEN}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]):
|
||||
"""Send a private DM to the requester informing them of a new comment."""
|
||||
user = await self.bot.fetch_user(requester_id)
|
||||
author = note.get("author", {}).get("name", "Someone")
|
||||
body = note.get("body", "")
|
||||
embed = disnake.Embed(
|
||||
title=f"Nouveau commentaire sur votre demande GitLab (#{issue_iid})",
|
||||
description=body,
|
||||
color=disnake.Color.blurple(),
|
||||
timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc),
|
||||
)
|
||||
embed.set_footer(text=f"Commentaire de {author}")
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
except disnake.HTTPException:
|
||||
# The user may have DMs disabled; ignore silently.
|
||||
pass
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_new_notes(self):
|
||||
"""Periodic task that checks for new notes on tracked issues and notifies requesters."""
|
||||
for issue_iid_str, info in list(self.issue_data.items()):
|
||||
issue_iid = int(issue_iid_str)
|
||||
try:
|
||||
notes = await self._get_issue_notes(issue_iid)
|
||||
except Exception as exc:
|
||||
# Skip this issue if we cannot fetch notes.
|
||||
continue
|
||||
# Find the newest note ID
|
||||
if not notes:
|
||||
continue
|
||||
latest_note = max(notes, key=lambda n: n["id"])
|
||||
last_known_id = info.get("last_note_id", 0)
|
||||
if latest_note["id"] > last_known_id:
|
||||
# New comment detected
|
||||
await self._notify_requester(info["requester_id"], issue_iid, latest_note)
|
||||
# Update stored last_note_id
|
||||
self.issue_data[issue_iid_str]["last_note_id"] = latest_note["id"]
|
||||
save_issue_tracking(self.issue_data)
|
||||
|
||||
# ---------- Slash command: /suggerer_fonctionnalite ----------
|
||||
@commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet")
|
||||
async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction):
|
||||
await inter.response.send_modal(modal=FeatureModal(self))
|
||||
|
||||
# ---------- Slash command: /signaler_bug ----------
|
||||
@commands.slash_command(name="notify_deploy", description="Envoyer un DM de confirmation du déploiement")
|
||||
async def notify_deploy(self, inter: disnake.ApplicationCommandInteraction):
|
||||
"""DM the user that the deployment is done."""
|
||||
try:
|
||||
await inter.author.send("✅ C'est fait ! Le code a été déployé sur la branche `main`.")
|
||||
await inter.response.send_message("✅ DM de confirmation envoyé.", ephemeral=True)
|
||||
except disnake.HTTPException:
|
||||
await inter.response.send_message("⚠️ Impossible d'envoyer le DM (DMs désactivés).", ephemeral=True)
|
||||
|
||||
async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction):
|
||||
await inter.response.send_modal(modal=BugModal(self))
|
||||
|
||||
# ---------- Modals ----------
|
||||
class FeatureModal(disnake.Modal):
|
||||
def __init__(self, cog: GitLabIntegration):
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre de la fonctionnalité",
|
||||
custom_id="title",
|
||||
style=disnake.TextInputStyle.short,
|
||||
max_length=100,
|
||||
required=True,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Description détaillée",
|
||||
custom_id="description",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
max_length=2000,
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
super().__init__(
|
||||
title="Suggestion de fonctionnalité",
|
||||
custom_id="feature_modal",
|
||||
components=components,
|
||||
)
|
||||
self.cog = cog
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction):
|
||||
title = inter.text_values["title"]
|
||||
description = inter.text_values["description"]
|
||||
await inter.response.defer(ephemeral=True)
|
||||
try:
|
||||
issue = await self.cog._create_gitlab_issue(
|
||||
title=title,
|
||||
description=description,
|
||||
labels=["feature", "discord-bot"],
|
||||
)
|
||||
# Store tracking info
|
||||
issue_iid = str(issue["iid"])
|
||||
self.cog.issue_data[issue_iid] = {
|
||||
"requester_id": inter.author.id,
|
||||
"last_note_id": 0,
|
||||
"type": "feature",
|
||||
}
|
||||
save_issue_tracking(self.cog.issue_data)
|
||||
await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})")
|
||||
except Exception as e:
|
||||
await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}")
|
||||
|
||||
class BugModal(disnake.Modal):
|
||||
def __init__(self, cog: GitLabIntegration):
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre du bug",
|
||||
custom_id="title",
|
||||
style=disnake.TextInputStyle.short,
|
||||
max_length=100,
|
||||
required=True,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Description et étapes pour reproduire",
|
||||
custom_id="description",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
max_length=2000,
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
super().__init__(
|
||||
title="Signalement de bug",
|
||||
custom_id="bug_modal",
|
||||
components=components,
|
||||
)
|
||||
self.cog = cog
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction):
|
||||
title = inter.text_values["title"]
|
||||
description = inter.text_values["description"]
|
||||
await inter.response.defer(ephemeral=True)
|
||||
try:
|
||||
issue = await self.cog._create_gitlab_issue(
|
||||
title=title,
|
||||
description=description,
|
||||
labels=["bug", "discord-bot"],
|
||||
)
|
||||
issue_iid = str(issue["iid"])
|
||||
self.cog.issue_data[issue_iid] = {
|
||||
"requester_id": inter.author.id,
|
||||
"last_note_id": 0,
|
||||
"type": "bug",
|
||||
}
|
||||
save_issue_tracking(self.cog.issue_data)
|
||||
await inter.edit_original_message(content=f"✅ Bug créé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue_iid})")
|
||||
except Exception as e:
|
||||
await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}")
|
||||
|
||||
def setup(bot: commands.Bot):
|
||||
bot.add_cog(GitLabIntegration(bot))
|
||||
|
|
@ -4,6 +4,7 @@ import sqlite3
|
|||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from src.logger import kuby_logger
|
||||
|
||||
class ModReasonModal(disnake.ui.Modal):
|
||||
def __init__(self, cog, action_type, member):
|
||||
|
|
@ -99,6 +100,7 @@ class Moderation(commands.Cog):
|
|||
guild_id INTEGER PRIMARY KEY,
|
||||
log_channel_id INTEGER,
|
||||
board_channel_id INTEGER,
|
||||
appeal_channel_id INTEGER,
|
||||
warn_limit_timeout INTEGER DEFAULT 3,
|
||||
warn_limit_kick INTEGER DEFAULT 5,
|
||||
warn_limit_ban INTEGER DEFAULT 10,
|
||||
|
|
@ -106,6 +108,8 @@ class Moderation(commands.Cog):
|
|||
)''')
|
||||
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN board_channel_id INTEGER")
|
||||
except: pass
|
||||
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN appeal_channel_id INTEGER")
|
||||
except: pass
|
||||
conn.commit(); conn.close()
|
||||
|
||||
def parse_duration(self, duration_str: str) -> int:
|
||||
|
|
@ -119,6 +123,60 @@ class Moderation(commands.Cog):
|
|||
def _type_to_emoji(self, s_type: str) -> str:
|
||||
return {"WARN": "⚠️", "TIMEOUT": "⏳", "KICK": "👢", "BAN": "🔨"}.get(s_type, "🛡️")
|
||||
|
||||
def _type_to_action_name(self, s_type: str) -> str:
|
||||
return {"WARN": "Avertissement", "TIMEOUT": "Timeout", "KICK": "Expulsion", "BAN": "Ban"}.get(s_type, "Sanction")
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
if seconds >= 86400:
|
||||
return f"{seconds // 86400} jour(s)"
|
||||
elif seconds >= 3600:
|
||||
return f"{seconds // 3600} heure(s)"
|
||||
elif seconds >= 60:
|
||||
return f"{seconds // 60} minute(s)"
|
||||
return f"{seconds} seconde(s)"
|
||||
|
||||
async def send_sanction_dm(self, user, s_type: str, reason: str, moderator, guild, duration: int = None) -> bool:
|
||||
"""Envoie un DM détaillé à l'utilisateur sanctionné. Retourne True si envoyé."""
|
||||
from commandes.modules_security.rules import load_settings as load_rules_settings
|
||||
|
||||
emoji = self._type_to_emoji(s_type)
|
||||
action_name = self._type_to_action_name(s_type)
|
||||
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT appeal_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
|
||||
res = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
rules_settings = load_rules_settings(guild.id)
|
||||
appeal_channel = guild.get_channel(res[0]) if res and res[0] else None
|
||||
rules_channel = guild.get_channel(rules_settings.get("rules_channel_id")) if rules_settings.get("rules_channel_id") else None
|
||||
|
||||
embed = disnake.Embed(
|
||||
title=f"{emoji} {action_name} sur **{guild.name}**",
|
||||
color=disnake.Color.red() if s_type in ("BAN", "KICK") else disnake.Color.orange()
|
||||
)
|
||||
embed.set_thumbnail(url=guild.icon.url if guild.icon else None)
|
||||
|
||||
embed.add_field(name="📋 Action", value=action_name, inline=True)
|
||||
embed.add_field(name="👤 Modérateur", value=f"**{moderator}**", inline=True)
|
||||
if duration:
|
||||
embed.add_field(name="⏱️ Durée", value=self._format_duration(duration), inline=True)
|
||||
|
||||
embed.add_field(name="📝 Raison", value=f"*{reason if reason else 'Aucune raison spécifiée'}*", inline=False)
|
||||
|
||||
if appeal_channel:
|
||||
embed.add_field(name="🔗 Contester cette sanction", value=f"Vous pouvez contester cette mesure dans {appeal_channel.mention}.", inline=False)
|
||||
|
||||
if rules_channel:
|
||||
embed.add_field(name="📜 Règlement", value=f"[Voir le règlement](https://discord.com/channels/{guild.id}/{rules_channel.id})", inline=False)
|
||||
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
return True
|
||||
except disnake.Forbidden:
|
||||
kuby_logger.warning(f"Impossible d'envoyer un DM à {user} (DMs bloqués)")
|
||||
return False
|
||||
|
||||
def build_sanction_board_components(self, *, s_type: str, user, moderator, reason, timestamp, s_id, duration=None) -> list:
|
||||
ts_int = int(datetime.fromisoformat(str(timestamp)).timestamp()) if not isinstance(timestamp, datetime) else int(timestamp.timestamp())
|
||||
emoji = self._type_to_emoji(s_type)
|
||||
|
|
@ -203,7 +261,7 @@ class Moderation(commands.Cog):
|
|||
|
||||
async def send_modconfig_v2(self, interaction: disnake.Interaction, edit=False):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT log_channel_id, board_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
|
||||
cursor.execute('SELECT log_channel_id, board_channel_id, appeal_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
if not res: return
|
||||
|
||||
|
|
@ -212,11 +270,13 @@ class Moderation(commands.Cog):
|
|||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"📁 **Logs internes :** <#{res[0]}>" if res[0] else "📁 **Logs :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"📢 **Affichage public :** <#{res[1]}>" if res[1] else "📢 **Affichage :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[2]} | K:{res[3]} | B:{res[4]}"),
|
||||
disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[5]}s"),
|
||||
disnake.ui.TextDisplay(f"🔗 **Contestation :** <#{res[2]}>" if res[2] else "🔗 **Contestation :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[3]} | K:{res[4]} | B:{res[5]}"),
|
||||
disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[6]}s"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.Section("📁 Salon des logs", accessory=disnake.ui.Button(label="Logs", style=disnake.ButtonStyle.primary, custom_id="modcfg_logs")),
|
||||
disnake.ui.Section("📢 Salon d'affichage", accessory=disnake.ui.Button(label="Public", style=disnake.ButtonStyle.primary, custom_id="modcfg_board")),
|
||||
disnake.ui.Section("🔗 Salon de contestation", accessory=disnake.ui.Button(label="Contester", style=disnake.ButtonStyle.primary, custom_id="modcfg_appeal")),
|
||||
disnake.ui.Section("⚖️ Modifier paliers", accessory=disnake.ui.Button(label="Paliers", style=disnake.ButtonStyle.secondary, custom_id="modcfg_limits")),
|
||||
disnake.ui.Section("⏱️ Durée timeout", accessory=disnake.ui.Button(label="Durée", style=disnake.ButtonStyle.secondary, custom_id="modcfg_duration"))
|
||||
]
|
||||
|
|
@ -229,6 +289,31 @@ class Moderation(commands.Cog):
|
|||
else:
|
||||
await interaction.response.send_message(components=components, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre")
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT type, reason, timestamp, moderator_id, duration FROM sanctions WHERE guild_id = ? AND user_id = ? ORDER BY timestamp DESC LIMIT 10', (interaction.guild_id, member.id))
|
||||
rows = cursor.fetchall(); conn.close()
|
||||
|
||||
if not rows:
|
||||
return await interaction.response.send_message(f"Aucune sanction trouvée pour {member.mention}.", ephemeral=True)
|
||||
|
||||
children = [
|
||||
disnake.ui.Section(f"📋 Historique : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)),
|
||||
disnake.ui.Separator(divider=True),
|
||||
]
|
||||
|
||||
for t, r, ts, mod_id, dur in rows:
|
||||
ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp())
|
||||
moderator = interaction.guild.get_member(mod_id)
|
||||
mod_name = moderator.name if moderator else f"Modérateur #{mod_id}"
|
||||
dur_str = f" ({self._format_duration(dur)})" if dur else ""
|
||||
children.append(disnake.ui.TextDisplay(f"{self._type_to_emoji(t)} **{t}{dur_str}** par **{mod_name}** — {r or 'Sans raison'} (<t:{ts_int}:R>)"))
|
||||
|
||||
components = [disnake.ui.Container(*children)]
|
||||
await interaction.response.send_message(components=components, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="modconfig", description="Configuration de la modération")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def modconfig(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
|
|
@ -297,6 +382,18 @@ class Moderation(commands.Cog):
|
|||
cursor.execute("SELECT timeout_duration FROM mod_config WHERE guild_id = ?", (interaction.guild_id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
await interaction.response.send_modal(ModConfigModal("Durée Timeout (s)", res[0]))
|
||||
elif act == "appeal":
|
||||
view = disnake.ui.View()
|
||||
sel = disnake.ui.ChannelSelect(placeholder="Choisir salon...", channel_types=[disnake.ChannelType.text])
|
||||
async def sel_cb(i):
|
||||
c = sel.values[0]
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute("UPDATE mod_config SET appeal_channel_id = ? WHERE guild_id = ?", (c.id, i.guild_id))
|
||||
conn.commit(); conn.close()
|
||||
await i.response.send_message(f"✅ Salon de contestation mis à jour.", ephemeral=True)
|
||||
await self.send_modconfig_v2(interaction, edit=True)
|
||||
sel.callback = sel_cb; view.add_item(sel)
|
||||
await interaction.response.send_message("Sélectionnez le salon pour la contestation :", view=view, ephemeral=True)
|
||||
|
||||
async def warn(self, interaction, member, reason):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
|
|
@ -311,8 +408,9 @@ class Moderation(commands.Cog):
|
|||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} averti ({count}).", ephemeral=True)
|
||||
await self.send_mod_log(interaction.guild, [disnake.ui.Container(disnake.ui.Section(f"⚠️ Warn : {member}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)), disnake.ui.Separator(divider=True), disnake.ui.TextDisplay(f"Raison: {reason}"), disnake.ui.TextDisplay(f"Total: {count}"))])
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="WARN", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
try: await member.send(f"⚠️ Avertissement sur **{interaction.guild.name}**\nRaison: {reason}")
|
||||
except: pass
|
||||
dm_sent = await self.send_sanction_dm(member, "WARN", reason, interaction.user, interaction.guild)
|
||||
if not dm_sent:
|
||||
kuby_logger.warning(f"DM non envoyé à {member} (DMs peut-être bloqués)")
|
||||
if cfg:
|
||||
lt, lk, lb, dur = cfg
|
||||
if count >= lb: await member.ban(reason=f"Auto: {count} warns")
|
||||
|
|
@ -329,22 +427,25 @@ class Moderation(commands.Cog):
|
|||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} timeout.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="TIMEOUT", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id, duration=secs))
|
||||
await self.send_sanction_dm(member, "TIMEOUT", reason, interaction.user, interaction.guild, duration=secs)
|
||||
|
||||
async def kick(self, interaction, member, reason):
|
||||
await member.kick(reason=reason)
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'KICK', reason))
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "KICK" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
await self.send_sanction_dm(member, "KICK", reason, interaction.user, interaction.guild)
|
||||
await member.kick(reason=reason)
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.name} expulsé.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="KICK", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
|
||||
async def ban(self, interaction, user, reason):
|
||||
await interaction.guild.ban(user, reason=reason)
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, user.id, interaction.user.id, 'BAN', reason))
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "BAN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, user.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
await self.send_sanction_dm(user, "BAN", reason, interaction.user, interaction.guild)
|
||||
await interaction.guild.ban(user, reason=reason)
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {user.name} banni.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="BAN", user=user, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ from typing import Optional
|
|||
SETTINGS_FILE = "data/security_settings.json"
|
||||
|
||||
|
||||
def escape_mentions(text: str) -> str:
|
||||
"""Échappe les mentions @everyone et @here pour éviter les notifications involontaires"""
|
||||
if not text:
|
||||
return text
|
||||
# Utilise un zero-width space pour briser la mention Discord
|
||||
return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
|
||||
|
||||
def load_settings(guild_id: int) -> dict:
|
||||
"""Charge les paramètres de sécurité pour un serveur"""
|
||||
if not os.path.exists(SETTINGS_FILE):
|
||||
|
|
@ -95,7 +103,7 @@ class RulesContentModal(disnake.ui.Modal):
|
|||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
self.settings["rules_content"] = self.content_input.value
|
||||
self.settings["rules_content"] = escape_mentions(self.content_input.value)
|
||||
save_settings(self.guild_id, self.settings)
|
||||
|
||||
if self.on_update_callback:
|
||||
|
|
@ -434,10 +442,11 @@ class RulesCog(commands.Cog):
|
|||
view = RulesAcceptButton(channel.guild.id, accept_role_id)
|
||||
|
||||
if message_type == "simple":
|
||||
# Message simple
|
||||
# Message simple - échappe les mentions @everyone/@here pour éviter les notifications involontaires
|
||||
safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
text_message = (
|
||||
f"📜 **{title}**\n\n"
|
||||
f"{content}\n\n"
|
||||
f"{safe_content}\n\n"
|
||||
"Bienvenue ! Pour accéder à l'ensemble des canaux du serveur, "
|
||||
"vous devez accepter le règlement en cliquant sur le bouton ci-dessous.\n\n"
|
||||
"En acceptant, vous confirmez avoir lu et compris les règles du serveur."
|
||||
|
|
@ -455,19 +464,29 @@ class RulesCog(commands.Cog):
|
|||
return None
|
||||
|
||||
else: # embed
|
||||
# Échappe les mentions @everyone/@here pour éviter les notifications involontaires
|
||||
safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
|
||||
# Utilise un Container avec TextDisplay pour un meilleur rendu du texte (markdown supporté)
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(safe_content)
|
||||
)
|
||||
]
|
||||
|
||||
# Créer l'embed
|
||||
embed = disnake.Embed(
|
||||
title=title,
|
||||
description=content,
|
||||
color=0x2b2d31
|
||||
)
|
||||
|
||||
embed.set_footer(text="Cliquez sur le bouton ci-dessous pour accepter le règlement")
|
||||
|
||||
# Envoyer seulement l'embed
|
||||
# Envoyer l'embed avec le container et la view
|
||||
try:
|
||||
message = await channel.send(
|
||||
embed=embed,
|
||||
components=components,
|
||||
view=view
|
||||
)
|
||||
return message
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ class TicketPanelView(disnake.ui.View):
|
|||
label="🎫 Créer un ticket",
|
||||
custom_id="ticket_create"
|
||||
)
|
||||
create_btn.callback = self._create_callback
|
||||
self.add_item(create_btn)
|
||||
|
||||
# Mes tickets (si l'utilisateur a des tickets)
|
||||
|
|
@ -85,7 +84,6 @@ class TicketPanelView(disnake.ui.View):
|
|||
label="📂 Mes tickets",
|
||||
custom_id="ticket_my_tickets"
|
||||
)
|
||||
my_tickets_btn.callback = self._my_tickets_callback
|
||||
self.add_item(my_tickets_btn)
|
||||
|
||||
# Analytics (staff only)
|
||||
|
|
@ -94,7 +92,6 @@ class TicketPanelView(disnake.ui.View):
|
|||
label="📊 Analytics",
|
||||
custom_id="ticket_analytics"
|
||||
)
|
||||
analytics_btn.callback = self._analytics_callback
|
||||
self.add_item(analytics_btn)
|
||||
|
||||
# Staff Ratings (staff only)
|
||||
|
|
@ -185,7 +182,7 @@ class TicketPanelView(disnake.ui.View):
|
|||
accessory=disnake.ui.Button(
|
||||
label=cat.name,
|
||||
style=disnake.ButtonStyle.primary,
|
||||
custom_id=f"cat_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}"
|
||||
custom_id=f"cat|{cat.id}|{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -543,7 +540,10 @@ class CategorySelectionView(disnake.ui.View):
|
|||
)
|
||||
return
|
||||
|
||||
# Show priority modal
|
||||
# Check if this is a recruitment category
|
||||
if category.is_recruitment and category.questions:
|
||||
modal = RecruitmentQuestionsModal(self.bot, self.config, category)
|
||||
else:
|
||||
modal = TicketPriorityModal(self.bot, self.config, category)
|
||||
await interaction.response.send_modal(modal)
|
||||
except Exception as e:
|
||||
|
|
@ -702,6 +702,206 @@ class TicketPriorityModal(disnake.ui.Modal):
|
|||
await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True)
|
||||
|
||||
|
||||
class RecruitmentQuestionsModal(disnake.ui.Modal):
|
||||
"""Modal pour les questions de recrutement"""
|
||||
|
||||
def __init__(self, bot, config, category: TicketCategory):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.category = category
|
||||
|
||||
# Construire dynamiquement les inputs de questions
|
||||
components = []
|
||||
self.question_inputs = {}
|
||||
|
||||
for i, question in enumerate(category.questions):
|
||||
input_field = disnake.ui.TextInput(
|
||||
label=f"Question {i+1}",
|
||||
custom_id=f"recruitment_q_{i}",
|
||||
placeholder=question,
|
||||
required=True,
|
||||
max_length=1000,
|
||||
style=disnake.TextInputStyle.paragraph
|
||||
)
|
||||
self.question_inputs[f"recruitment_q_{i}"] = input_field
|
||||
components.append(input_field)
|
||||
|
||||
super().__init__(title=f"Recrutement: {category.name}", components=components)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
# Collecter les réponses
|
||||
responses = {}
|
||||
for key, input_field in self.question_inputs.items():
|
||||
responses[key] = interaction.text_values.get(key, "")
|
||||
|
||||
# Créer le ticket avec les réponses stockées
|
||||
await self._create_recruitment_ticket(interaction, responses)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"Erreur lors de la création du ticket: {e}", ephemeral=True)
|
||||
|
||||
async def _create_recruitment_ticket(self, interaction: disnake.Interaction, responses: Dict[str, str]):
|
||||
"""Crée le ticket de recrutement"""
|
||||
guild = interaction.guild
|
||||
user = interaction.user
|
||||
|
||||
storage = get_storage()
|
||||
|
||||
# Build permissions
|
||||
overwrites = {
|
||||
guild.default_role: disnake.PermissionOverwrite(read_messages=False),
|
||||
user: disnake.PermissionOverwrite(read_messages=True, send_messages=True),
|
||||
guild.me: disnake.PermissionOverwrite(read_messages=True, send_messages=True)
|
||||
}
|
||||
|
||||
# Add staff roles (category-specific roles override global ones)
|
||||
staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles
|
||||
for role_id in staff_roles_to_add:
|
||||
try:
|
||||
role = guild.get_role(int(role_id))
|
||||
if role and role not in overwrites:
|
||||
overwrites[role] = disnake.PermissionOverwrite(read_messages=True, send_messages=True)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Create channel name
|
||||
safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip()
|
||||
if not safe_username:
|
||||
safe_username = "user"
|
||||
safe_category = "".join(c for c in self.category.name if not c.isdigit()).strip()
|
||||
channel_name = f"recrutement-{safe_username}"
|
||||
|
||||
try:
|
||||
category_channel = None
|
||||
if self.category.discord_category_id:
|
||||
try:
|
||||
category_channel = guild.get_channel(int(self.category.discord_category_id))
|
||||
if category_channel and not isinstance(category_channel, disnake.CategoryChannel):
|
||||
category_channel = None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not category_channel and self.config.default_category:
|
||||
try:
|
||||
category_channel = guild.get_channel(int(self.config.default_category))
|
||||
if category_channel and not isinstance(category_channel, disnake.CategoryChannel):
|
||||
category_channel = None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
ticket_channel = await guild.create_text_channel(
|
||||
name=channel_name,
|
||||
category=category_channel,
|
||||
overwrites=overwrites,
|
||||
topic=f"Recrutement de {user} | Catégorie: {self.category.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"Erreur: {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
# Create ticket data
|
||||
ticket_data = TicketData(
|
||||
channel_id=ticket_channel.id,
|
||||
guild_id=guild.id,
|
||||
user_id=user.id,
|
||||
category_id=self.category.id,
|
||||
priority=Priority.NORMAL,
|
||||
status=TicketStatus.OPEN,
|
||||
reason="Recrutement",
|
||||
recruitment_responses=responses
|
||||
)
|
||||
storage.save_ticket(ticket_data)
|
||||
|
||||
# Build mentions for staff
|
||||
mentions = [user.mention]
|
||||
for role_id in staff_roles_to_add:
|
||||
mentions.append(f"<@&{role_id}>")
|
||||
|
||||
mention_content = " ".join(mentions)
|
||||
|
||||
# Send welcome message in ticket channel
|
||||
welcome_text = f"Bienvenue {user.mention} !\n\nMerci de votre intérêt pour rejoindre notre équipe.\n\n"
|
||||
welcome_text += "Nos responsables vont analyser vos réponses et vous contacteront.\n\n"
|
||||
|
||||
# Afficher les questions et réponses
|
||||
welcome_text += "**Vos réponses:**\n"
|
||||
for i, question in enumerate(self.category.questions):
|
||||
key = f"recruitment_q_{i}"
|
||||
response = responses.get(key, "Non répondu")
|
||||
welcome_text += f"> **{question}**\n> {response}\n\n"
|
||||
|
||||
# Create container with management buttons and recruitment responses
|
||||
sections = [
|
||||
disnake.ui.TextDisplay(content=welcome_text),
|
||||
disnake.ui.Separator(divider=True)
|
||||
]
|
||||
|
||||
# Add management buttons if staff
|
||||
sections.append(
|
||||
disnake.ui.Section(
|
||||
"Actions",
|
||||
accessory=disnake.ui.Button(
|
||||
label="🔒 Fermer (Accepté/Refusé)",
|
||||
style=disnake.ButtonStyle.green,
|
||||
custom_id="recruitment_close"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
container = disnake.ui.Container(*sections)
|
||||
await ticket_channel.send(components=[disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(content=mention_content),
|
||||
disnake.ui.Separator(divider=True),
|
||||
*sections
|
||||
)])
|
||||
|
||||
# Envoyer les réponses dans le salon staff si configuré
|
||||
if self.category.responses_channel_id:
|
||||
try:
|
||||
responses_channel = guild.get_channel(self.category.responses_channel_id)
|
||||
if responses_channel:
|
||||
embed = disnake.Embed(
|
||||
title=f"📝 Nouveau Candidature - {user}",
|
||||
description=f"**Candidature dans** {ticket_channel.mention}",
|
||||
color=disnake.Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
|
||||
for i, question in enumerate(self.category.questions):
|
||||
key = f"recruitment_q_{i}"
|
||||
response = responses.get(key, "Non répondu")
|
||||
embed.add_field(
|
||||
name=f"Q{i+1}: {question}",
|
||||
value=response,
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.add_field(name="Candidat", value=user.mention, inline=True)
|
||||
embed.add_field(name="Discord ID", value=str(user.id), inline=True)
|
||||
|
||||
await responses_channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
print(f"Error sending recruitment responses to staff channel: {e}")
|
||||
|
||||
# Log
|
||||
if self.config.log_channel_id:
|
||||
log_channel = guild.get_channel(self.config.log_channel_id)
|
||||
if log_channel:
|
||||
log_embed = disnake.Embed(
|
||||
title="🎓 Nouveau Ticket de Recrutement",
|
||||
color=disnake.Color.purple(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
log_embed.add_field(name="Utilisateur", value=user.mention, inline=True)
|
||||
log_embed.add_field(name="Catégorie", value=self.category.name, inline=True)
|
||||
log_embed.add_field(name="Salon", value=ticket_channel.mention, inline=True)
|
||||
await log_channel.send(embed=log_embed)
|
||||
|
||||
await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True)
|
||||
|
||||
|
||||
class SetCategoryModal(disnake.ui.Modal):
|
||||
"""Modal pour définir la catégorie Discord où les tickets seront créés"""
|
||||
|
||||
|
|
@ -2101,7 +2301,39 @@ class AddCategoryModal(disnake.ui.Modal):
|
|||
self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True)
|
||||
self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph)
|
||||
self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5)
|
||||
super().__init__(title="Ajouter une Catégorie", components=[self.name_input, self.desc_input, self.emoji_input])
|
||||
# Recruitment fields
|
||||
self.is_recruitment_input = disnake.ui.TextInput(
|
||||
label="Recrutement? (oui/non)",
|
||||
custom_id="add_cat_recruitment",
|
||||
placeholder="non",
|
||||
required=False,
|
||||
max_length=5
|
||||
)
|
||||
self.questions_input = disnake.ui.TextInput(
|
||||
label="Questions recrutement (une par ligne)",
|
||||
custom_id="add_cat_questions",
|
||||
placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?",
|
||||
required=False,
|
||||
style=disnake.TextInputStyle.paragraph
|
||||
)
|
||||
self.responses_channel_input = disnake.ui.TextInput(
|
||||
label="ID salon pour les réponses",
|
||||
custom_id="add_cat_responses_channel",
|
||||
placeholder="ID du salon staff où envoyer les réponses",
|
||||
required=False,
|
||||
max_length=20
|
||||
)
|
||||
super().__init__(
|
||||
title="Ajouter une Catégorie",
|
||||
components=[
|
||||
self.name_input,
|
||||
self.desc_input,
|
||||
self.emoji_input,
|
||||
self.is_recruitment_input,
|
||||
self.questions_input,
|
||||
self.responses_channel_input
|
||||
]
|
||||
)
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.config = config
|
||||
|
|
@ -2114,18 +2346,39 @@ class AddCategoryModal(disnake.ui.Modal):
|
|||
name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie")
|
||||
desc_val = interaction.text_values.get("add_cat_desc", "")
|
||||
emoji_val = interaction.text_values.get("add_cat_emoji", "🎫")
|
||||
is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non")
|
||||
questions_val = interaction.text_values.get("add_cat_questions", "")
|
||||
responses_channel_val = interaction.text_values.get("add_cat_responses_channel", "")
|
||||
|
||||
is_recruitment = is_recruitment_val.lower() in ["oui", "yes", "o", "y", "1", "true"]
|
||||
questions = [q.strip() for q in questions_val.split("\n") if q.strip()] if questions_val else []
|
||||
responses_channel_id = None
|
||||
if responses_channel_val and responses_channel_val.isdigit():
|
||||
responses_channel_id = int(responses_channel_val)
|
||||
|
||||
category = TicketCategory(
|
||||
id=cat_id,
|
||||
name=name_val,
|
||||
description=desc_val,
|
||||
emoji=emoji_val
|
||||
emoji=emoji_val,
|
||||
is_recruitment=is_recruitment,
|
||||
questions=questions,
|
||||
responses_channel_id=responses_channel_id
|
||||
)
|
||||
|
||||
self.config.categories.append(category)
|
||||
self.storage.save_config(interaction.guild_id, self.config)
|
||||
|
||||
await interaction.response.send_message(f"Catégorie **{name_val}** créée!", ephemeral=True)
|
||||
recruitment_info = ""
|
||||
if is_recruitment:
|
||||
recruitment_info = f"\n✅ Mode recrutement activé avec {len(questions)} question(s)"
|
||||
if responses_channel_id:
|
||||
recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>"
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Catégorie **{name_val}** créée!{recruitment_info}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class EditCategoryModal(disnake.ui.Modal):
|
||||
|
|
@ -2646,8 +2899,17 @@ class PermissionConfigView(disnake.ui.View):
|
|||
# Determine which list to update
|
||||
if self.config_type == "staff":
|
||||
target_list = self.config.staff_roles
|
||||
else:
|
||||
elif self.config_type == "config":
|
||||
target_list = self.config.config_roles
|
||||
else:
|
||||
# Category-specific staff roles (config_type = "cat_{cat_id}")
|
||||
cat_id = self.config_type.replace("cat_", "")
|
||||
category = self.config.get_category(cat_id)
|
||||
if category:
|
||||
target_list = category.staff_roles
|
||||
else:
|
||||
await interaction.response.send_message("Catégorie introuvable.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Get new selected IDs
|
||||
new_ids = interaction.data.get('values', [])
|
||||
|
|
@ -2674,8 +2936,11 @@ class PermissionConfigView(disnake.ui.View):
|
|||
try:
|
||||
if self.config_type == "staff":
|
||||
target_list = self.config.staff_users
|
||||
else:
|
||||
elif self.config_type == "config":
|
||||
target_list = self.config.config_users
|
||||
else:
|
||||
# Category-specific staff - users not separately managed per category, use global staff_users
|
||||
target_list = self.config.staff_users
|
||||
|
||||
new_ids = [int(uid) for uid in interaction.data.get('values', [])]
|
||||
added_count = 0
|
||||
|
|
@ -2705,8 +2970,19 @@ class PermissionConfigView(disnake.ui.View):
|
|||
await interaction.response.edit_message(components=self.get_components_v2())
|
||||
|
||||
def get_components_v2(self):
|
||||
title = "🛡️ Permissions Staff" if self.config_type == "staff" else "⚙️ Permissions Config"
|
||||
desc = "Configurez qui peut gérer les tickets." if self.config_type == "staff" else "Configurez qui peut modifier les paramètres."
|
||||
if self.config_type == "staff":
|
||||
title = "🛡️ Permissions Staff"
|
||||
desc = "Configurez qui peut gérer les tickets."
|
||||
elif self.config_type == "config":
|
||||
title = "⚙️ Permissions Config"
|
||||
desc = "Configurez qui peut modifier les paramètres."
|
||||
else:
|
||||
# Category-specific staff roles
|
||||
cat_id = self.config_type.replace("cat_", "")
|
||||
category = self.config.get_category(cat_id)
|
||||
cat_name = category.name if category else f"Catégorie {cat_id}"
|
||||
title = f"🛡️ Permissions Staff - {cat_name}"
|
||||
desc = f"Configurez les rôles staff ayant accès à la catégorie '{cat_name}'."
|
||||
|
||||
sections = [
|
||||
disnake.ui.TextDisplay(content=f"# {title}\n{desc}"),
|
||||
|
|
@ -2726,9 +3002,18 @@ class PermissionConfigView(disnake.ui.View):
|
|||
if self.config_type == "staff":
|
||||
roles = self.config.staff_roles
|
||||
users = self.config.staff_users
|
||||
else:
|
||||
elif self.config_type == "config":
|
||||
roles = self.config.config_roles
|
||||
users = self.config.config_users
|
||||
else:
|
||||
# Category-specific staff roles
|
||||
cat_id = self.config_type.replace("cat_", "")
|
||||
category = self.config.get_category(cat_id)
|
||||
if category:
|
||||
roles = category.staff_roles
|
||||
else:
|
||||
roles = []
|
||||
users = self.config.staff_users # Category uses global staff_users
|
||||
|
||||
# Limit to 25 items for removal dropdown (API Limit)
|
||||
# If more, we might need pagination or just show first 25.
|
||||
|
|
@ -2780,12 +3065,29 @@ class PermissionConfigView(disnake.ui.View):
|
|||
id_val = int(id_str)
|
||||
|
||||
if prefix == 'r': # Role
|
||||
target_list = self.config.staff_roles if self.config_type == "staff" else self.config.config_roles
|
||||
if self.config_type == "staff":
|
||||
target_list = self.config.staff_roles
|
||||
elif self.config_type == "config":
|
||||
target_list = self.config.config_roles
|
||||
else:
|
||||
# Category-specific staff roles
|
||||
cat_id = self.config_type.replace("cat_", "")
|
||||
category = self.config.get_category(cat_id)
|
||||
if category:
|
||||
target_list = category.staff_roles
|
||||
else:
|
||||
continue
|
||||
if str(id_val) in target_list:
|
||||
target_list.remove(str(id_val))
|
||||
removed_count += 1
|
||||
elif prefix == 'u': # User
|
||||
target_list = self.config.staff_users if self.config_type == "staff" else self.config.config_users
|
||||
if self.config_type == "staff":
|
||||
target_list = self.config.staff_users
|
||||
elif self.config_type == "config":
|
||||
target_list = self.config.config_users
|
||||
else:
|
||||
# Category-specific staff uses global staff_users
|
||||
target_list = self.config.staff_users
|
||||
if id_val in target_list:
|
||||
target_list.remove(id_val)
|
||||
removed_count += 1
|
||||
|
|
@ -2851,7 +3153,7 @@ class TicketCommands(commands.Cog):
|
|||
if not (
|
||||
str(custom_id).startswith("ticket_")
|
||||
or str(custom_id).startswith("cat_")
|
||||
or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"}
|
||||
or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel", "recruitment_close", "recruitment_accept", "recruitment_refuse"}
|
||||
):
|
||||
return
|
||||
|
||||
|
|
@ -2885,23 +3187,49 @@ class TicketCommands(commands.Cog):
|
|||
view = TicketPanelView(self.bot, config, self.storage)
|
||||
await view._analytics_callback(inter)
|
||||
|
||||
# ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = cat_{cat.id}_{snowflake}
|
||||
elif custom_id.startswith("cat_"):
|
||||
# ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = cat|<catid>|<suffix>
|
||||
elif str(custom_id).startswith("cat|"):
|
||||
try:
|
||||
# format: cat_<catid>_<suffix>
|
||||
parts = custom_id.split("_")
|
||||
if len(parts) >= 3:
|
||||
cat_id = "_".join(parts[1:-1])
|
||||
else:
|
||||
cat_id = parts[1] if len(parts) > 1 else None
|
||||
# format: cat|<catid>|<suffix>
|
||||
parts = str(custom_id).split("|")
|
||||
if len(parts) < 3:
|
||||
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
|
||||
return
|
||||
|
||||
category = next((c for c in config.categories if c.id == cat_id), None)
|
||||
cat_id = parts[1]
|
||||
category = next((c for c in config.categories if str(c.id) == str(cat_id)), None)
|
||||
if not category:
|
||||
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
|
||||
return
|
||||
|
||||
# ouvrir directement le modal de priorité (même logique que CategorySelectionView)
|
||||
# Check if recruitment category with questions
|
||||
if category.is_recruitment and category.questions:
|
||||
modal = RecruitmentQuestionsModal(self.bot, config, category)
|
||||
else:
|
||||
modal = TicketPriorityModal(self.bot, config, category)
|
||||
|
||||
await inter.response.send_modal(modal)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[TicketSystem] cat| routing error: {e}", exc_info=True)
|
||||
if not inter.response.is_done():
|
||||
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
|
||||
|
||||
# backward compatibility (legacy cat_<id>_<suffix>)
|
||||
elif custom_id.startswith("cat_"):
|
||||
try:
|
||||
parts = str(custom_id).split("_")
|
||||
cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None)
|
||||
|
||||
category = next((c for c in config.categories if str(c.id) == str(cat_id)), None)
|
||||
if not category:
|
||||
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
|
||||
return
|
||||
|
||||
if category.is_recruitment and category.questions:
|
||||
modal = RecruitmentQuestionsModal(self.bot, config, category)
|
||||
else:
|
||||
modal = TicketPriorityModal(self.bot, config, category)
|
||||
|
||||
await inter.response.send_modal(modal)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True)
|
||||
|
|
@ -2934,6 +3262,80 @@ class TicketCommands(commands.Cog):
|
|||
view = TicketManagementView(self.bot, self.storage, config, ticket)
|
||||
await view._transcript_callback(inter)
|
||||
|
||||
# === Recrutement: Fermeture avec Accepté/Refusé ===
|
||||
elif custom_id == "recruitment_close":
|
||||
ticket = self.storage.load_ticket(inter.channel.id)
|
||||
if not ticket:
|
||||
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
category = config.get_category(ticket.category_id)
|
||||
if not category or not category.is_recruitment:
|
||||
# Si pas un ticket recrutement, utiliser la fermeture normale
|
||||
view = TicketManagementView(self.bot, self.storage, config, ticket)
|
||||
await view._close_callback(inter)
|
||||
return
|
||||
|
||||
# Vérifier que c'est du staff
|
||||
if not is_staff(inter, config, category):
|
||||
await inter.response.send_message("Réservé au staff.", ephemeral=True)
|
||||
return
|
||||
|
||||
await inter.response.send_message(
|
||||
"Choisissez la décision pour cette candidature:",
|
||||
components=[
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
"# 🎓 Décision de Recrutement",
|
||||
accessory=disnake.ui.Button(
|
||||
label="✅ Accepter",
|
||||
style=disnake.ButtonStyle.green,
|
||||
custom_id="recruitment_accept"
|
||||
)
|
||||
),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.Section(
|
||||
"Refuser la candidature",
|
||||
accessory=disnake.ui.Button(
|
||||
label="❌ Refuser",
|
||||
style=disnake.ButtonStyle.red,
|
||||
custom_id="recruitment_refuse"
|
||||
)
|
||||
)
|
||||
)
|
||||
],
|
||||
flags=disnake.MessageFlags(is_components_v2=True),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
elif custom_id == "recruitment_accept":
|
||||
ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None
|
||||
if not ticket:
|
||||
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
category = config.get_category(ticket.category_id)
|
||||
if not is_staff(inter, config, category):
|
||||
await inter.response.send_message("Réservé au staff.", ephemeral=True)
|
||||
return
|
||||
|
||||
await inter.response.defer()
|
||||
await self._send_recruitment_decision(inter, ticket, category, accepted=True)
|
||||
|
||||
elif custom_id == "recruitment_refuse":
|
||||
ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None
|
||||
if not ticket:
|
||||
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
category = config.get_category(ticket.category_id)
|
||||
if not is_staff(inter, config, category):
|
||||
await inter.response.send_message("Réservé au staff.", ephemeral=True)
|
||||
return
|
||||
|
||||
await inter.response.defer()
|
||||
await self._send_recruitment_decision(inter, ticket, category, accepted=False)
|
||||
|
||||
# === Fermeture via composants V2 (sans view=, donc custom_id) ===
|
||||
elif custom_id == "close_cancel":
|
||||
await inter.response.defer()
|
||||
|
|
@ -3260,6 +3662,84 @@ class TicketCommands(commands.Cog):
|
|||
except Exception as e:
|
||||
print(f"[Ticket] Error in _register_all_views: {e}")
|
||||
|
||||
async def _send_recruitment_decision(self, inter: disnake.Interaction, ticket, category, accepted: bool):
|
||||
"""Envoie la décision de recrutement (Accepté/Refusé) au candidat via DM"""
|
||||
try:
|
||||
guild = inter.guild
|
||||
user = guild.get_member(ticket.user_id)
|
||||
if not user:
|
||||
try:
|
||||
user = await guild.fetch_member(ticket.user_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Déterminer le message selon la décision
|
||||
if accepted:
|
||||
title = "✅ Candidature Acceptée !"
|
||||
color = disnake.Color.green()
|
||||
description = (
|
||||
f"Félicitations {user.mention} !\n\n"
|
||||
f"Votre candidature pour **Boga Life** a été acceptée.\n\n"
|
||||
f"Un responsable va bientôt vous contacter pour les prochaines étapes.\n\n"
|
||||
f"Bienvenue dans l'équipe ! 🎉"
|
||||
)
|
||||
else:
|
||||
title = "❌ Candidature Refusée"
|
||||
color = disnake.Color.red()
|
||||
description = (
|
||||
f"Bonjour {user.mention},\n\n"
|
||||
f"Après analyse de votre candidature, nous avons le regret de vous informer "
|
||||
f"que celle-ci n'a pas été retenue pour le poste.\n\n"
|
||||
f"Cette décision ne remet pas en cause vos qualités. N'hésitez pas à repostuler "
|
||||
f"ultérieurement si vous êtes toujours intéressé(e).\n\n"
|
||||
f"Nous vous souhaitons bonne continuation."
|
||||
)
|
||||
|
||||
embed = disnake.Embed(
|
||||
title=title,
|
||||
description=description,
|
||||
color=color,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
embed.set_footer(text="Boga Life - Système de Recrutement")
|
||||
|
||||
# Envoyer le DM à l'utilisateur
|
||||
if user:
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
await inter.followup.send("✅ Décision envoyée au candidat par DM.", ephemeral=True)
|
||||
except disnake.Forbidden:
|
||||
await inter.followup.send("⚠️ Impossible d'envoyer un DM - l'utilisateur a ses messages privés fermés.", ephemeral=True)
|
||||
except Exception as e:
|
||||
await inter.followup.send(f"⚠️ Erreur lors de l'envoi du DM: {e}", ephemeral=True)
|
||||
else:
|
||||
await inter.followup.send("⚠️ Utilisateur introuvable.", ephemeral=True)
|
||||
|
||||
# Fermer le ticket
|
||||
ticket.status = TicketStatus.CLOSED
|
||||
ticket.closed_at = datetime.now()
|
||||
self.storage.save_ticket(ticket)
|
||||
|
||||
# Envoyer un message dans le salon du ticket
|
||||
close_embed = disnake.Embed(
|
||||
title=f"🔒 Ticket Fermé - {'Accepté' if accepted else 'Refusé'}",
|
||||
description=f"Candidature traitée par {inter.user.mention}",
|
||||
color=color
|
||||
)
|
||||
close_embed.add_field(name="Decision", value="✅ Acceptee" if accepted else "❌ Refusee", inline=True)
|
||||
|
||||
# Supprimer le salon
|
||||
try:
|
||||
await inter.channel.delete(reason=f"Recrutement termine - {'Accepte' if accepted else 'Refuse'} par {inter.user}")
|
||||
except Exception as e:
|
||||
await inter.channel.send(embed=close_embed)
|
||||
print(f"Error deleting recruitment channel: {e}")
|
||||
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[TicketSystem] _send_recruitment_decision failed: {e}", exc_info=True)
|
||||
if not inter.response.is_done():
|
||||
await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True)
|
||||
|
||||
# === COMMANDES ===
|
||||
|
||||
@commands.slash_command(name="ticket", description="Ouvre le panel des tickets")
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ class TicketCategory:
|
|||
priority_enabled: bool = True,
|
||||
allow_claims: bool = True,
|
||||
survey_enabled: bool = True,
|
||||
discord_category_id: Optional[int] = None
|
||||
discord_category_id: Optional[int] = None,
|
||||
is_recruitment: bool = False,
|
||||
questions: List[str] = None,
|
||||
responses_channel_id: Optional[int] = None
|
||||
):
|
||||
self.id = id
|
||||
self.name = name
|
||||
|
|
@ -76,6 +79,9 @@ class TicketCategory:
|
|||
self.allow_claims = allow_claims
|
||||
self.survey_enabled = survey_enabled
|
||||
self.discord_category_id = discord_category_id
|
||||
self.is_recruitment = is_recruitment
|
||||
self.questions = questions or []
|
||||
self.responses_channel_id = responses_channel_id
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert category to dictionary for storage"""
|
||||
|
|
@ -93,7 +99,10 @@ class TicketCategory:
|
|||
"priority_enabled": self.priority_enabled,
|
||||
"allow_claims": self.allow_claims,
|
||||
"survey_enabled": self.survey_enabled,
|
||||
"discord_category_id": self.discord_category_id
|
||||
"discord_category_id": self.discord_category_id,
|
||||
"is_recruitment": self.is_recruitment,
|
||||
"questions": self.questions,
|
||||
"responses_channel_id": self.responses_channel_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -113,7 +122,10 @@ class TicketCategory:
|
|||
priority_enabled=data.get("priority_enabled", True),
|
||||
allow_claims=data.get("allow_claims", True),
|
||||
survey_enabled=data.get("survey_enabled", True),
|
||||
discord_category_id=data.get("discord_category_id")
|
||||
discord_category_id=data.get("discord_category_id"),
|
||||
is_recruitment=data.get("is_recruitment", False),
|
||||
questions=data.get("questions", []),
|
||||
responses_channel_id=data.get("responses_channel_id")
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -188,6 +200,7 @@ class TicketData:
|
|||
transcript_path: Optional[str] = None
|
||||
rating: Optional[int] = None
|
||||
feedback: Optional[str] = None
|
||||
recruitment_responses: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
|
|
@ -214,7 +227,8 @@ class TicketData:
|
|||
"reason": self.reason,
|
||||
"transcript_path": self.transcript_path,
|
||||
"rating": self.rating,
|
||||
"feedback": self.feedback
|
||||
"feedback": self.feedback,
|
||||
"recruitment_responses": self.recruitment_responses
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -234,7 +248,8 @@ class TicketData:
|
|||
reason=data.get("reason", ""),
|
||||
transcript_path=data.get("transcript_path"),
|
||||
rating=data.get("rating"),
|
||||
feedback=data.get("feedback")
|
||||
feedback=data.get("feedback"),
|
||||
recruitment_responses=data.get("recruitment_responses", {})
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
5
kuby.py
5
kuby.py
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from bot import bot
|
||||
from bot import MyBot # Modification ici : on importe la classe
|
||||
from src.logger import kuby_logger, log_performance
|
||||
|
||||
kuby_logger.info("🚀 Lancement du bot Kuby...")
|
||||
|
|
@ -28,6 +28,9 @@ kuby_logger.info("✅ Token Discord chargé avec succès")
|
|||
async def main():
|
||||
kuby_logger.info("🔄 Démarrage de la connexion Discord...")
|
||||
try:
|
||||
# Modification majeure : l'objet bot est instancié ici, au sein de la boucle active
|
||||
bot = MyBot()
|
||||
|
||||
await bot.do_async_setup()
|
||||
kuby_logger.info("🔗 Connexion établie avec Discord...")
|
||||
await bot.start(TOKEN)
|
||||
|
|
|
|||
|
|
@ -123,23 +123,30 @@ class AdvancedLogger:
|
|||
try:
|
||||
logs = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback: try to strip brackets and commas
|
||||
content_clean = content.strip('[]').strip()
|
||||
# This is getting complex, let's just try line-by-line fallback
|
||||
# File is corrupt or semi-corrupt, try line-by-line
|
||||
pass
|
||||
|
||||
# If not a list or failed list parse, try NDJSON line-by-line
|
||||
if not logs:
|
||||
for line in content.splitlines():
|
||||
# Try NDJSON line-by-line (handles both legacy and current format)
|
||||
lines = content.splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
# Strip trailing/leading commas in case of semi-corrupt legacy conversion
|
||||
if not line:
|
||||
continue
|
||||
# Strip trailing/leading commas in case of semi-corrupt lines
|
||||
line = line.strip(',')
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
logs.append(json.loads(line))
|
||||
parsed = json.loads(line)
|
||||
if isinstance(parsed, dict):
|
||||
logs.append(parsed)
|
||||
except json.JSONDecodeError:
|
||||
continue # Skip corrupt lines
|
||||
|
||||
# Guard against empty logs list after parsing
|
||||
if not logs:
|
||||
return None
|
||||
|
||||
# Find the most recent leave event
|
||||
leave_events = [log for log in logs if isinstance(log, dict) and log.get("event_type") == "member_leave"]
|
||||
if leave_events:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue