Merge branch 'main' into 'dev'

# Conflicts:
#   commandes/ticket/__init__.py
This commit is contained in:
Gameur 2026-05-31 12:25:01 +02:00
commit 9f92d0d212
8 changed files with 761 additions and 149 deletions

11
TODO.md
View file

@ -1,11 +0,0 @@
# TODO - Kuby
- [x] Identifier le cog qui démarre le serveur aiohttp sur 127.0.0.1:5001 (bug_report.py)
- [x] Remplacer lasync hook `cog_load` par un démarrage différé via `__init__` + `bot.loop.create_task` (disnake 2.12.0)
- [x] Renommer la logique serveur en `_start_webhook_server`
- [x] Attendre `await bot.wait_until_ready()` avant douvrir le port
- [x] Conserver le retry bind port 5001 (OSError errno 98)
- [ ] Redémarrer le bot
- [ ] Vérifier dans les logs: "Internal Bot Webhook Server started on 127.0.0.1:5001"

7
bot.py
View file

@ -189,6 +189,13 @@ class MyBot(commands.Bot):
is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504]
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

View file

@ -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 ""
@ -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,6 +163,16 @@ 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
@ -252,7 +263,7 @@ class BugReport(commands.Cog):
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 ''))}"
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()
@ -300,11 +311,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(
@ -312,8 +322,6 @@ class BugReport(commands.Cog):
)
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}")
@ -343,18 +351,15 @@ class BugReport(commands.Cog):
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}).")
error_code = getattr(e, 'code', None)
if isinstance(e, disnake.Forbidden) or error_code == 50007:
kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}) - MPs désactivés.")
if dev and dev.id != user.id:
await dev.send(
f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)."
f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid}."
)
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}")
@ -400,9 +405,12 @@ class BugReport(commands.Cog):
)
except (disnake.Forbidden, disnake.HTTPException):
if dev and dev.id != user.id:
await dev.send(
f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés)."
)
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}",
@ -419,31 +427,19 @@ 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"},
),
):
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))
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):

View file

@ -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,20 +261,22 @@ 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
children = [
disnake.ui.Section("⚙️ Configuration Modération", accessory=disnake.ui.Thumbnail(interaction.guild.icon.url if interaction.guild.icon else None)),
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))

View file

@ -12,6 +12,14 @@ SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
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):
@ -99,7 +107,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:
@ -438,10 +446,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."
@ -459,19 +468,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

View file

@ -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)
@ -547,9 +544,12 @@ class CategorySelectionView(disnake.ui.View):
ephemeral=True
)
return
# Show priority modal
modal = TicketPriorityModal(self.bot, self.config, category)
# 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:
kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True)
@ -598,7 +598,7 @@ class TicketPriorityModal(disnake.ui.Modal):
async def on_error(self, interaction: disnake.Interaction, error: Exception):
"""Handle errors in the modal"""
await interaction.response.send_message("Une erreur inattendue s'est produite lors de la création du ticket.", ephemeral=True)
async def _create_ticket(self, interaction: disnake.Interaction, priority: Priority, reason: str):
"""Crée le ticket"""
guild = interaction.guild
@ -707,6 +707,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"""
@ -2106,31 +2306,84 @@ 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
async def callback(self, interaction: disnake.ModalInteraction):
import uuid
cat_id = str(uuid.uuid4())[:8]
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):
@ -2655,18 +2908,27 @@ 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', [])
added_count = 0
for new_id in new_ids:
if new_id not in target_list:
target_list.append(new_id)
added_count += 1
self.storage.save_config(interaction.guild_id, self.config)
@ -2683,17 +2945,20 @@ 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
for new_id in new_ids:
if new_id not in target_list:
target_list.append(new_id)
added_count += 1
self.storage.save_config(interaction.guild_id, self.config)
@ -2714,9 +2979,20 @@ 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}"),
disnake.ui.Separator(divider=True),
@ -2731,17 +3007,26 @@ class PermissionConfigView(disnake.ui.View):
def _add_remove_select(self):
# Build options from current config
options = []
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.
count = 0
for r_id in roles:
if count >= 25: break
@ -2754,7 +3039,7 @@ class PermissionConfigView(disnake.ui.View):
description="Cliquez pour retirer"
))
count += 1
for u_id in users:
if count >= 25: break
user = self.guild.get_member(u_id)
@ -2766,7 +3051,7 @@ class PermissionConfigView(disnake.ui.View):
description="Cliquez pour retirer"
))
count += 1
if options:
select = disnake.ui.Select(
placeholder="Sélectionnez pour RETIRER des permissions...",
@ -2782,23 +3067,40 @@ class PermissionConfigView(disnake.ui.View):
async def _remove_callback(self, interaction: disnake.Interaction):
try:
values = interaction.data.get('values', [])
removed_count = 0
for val in values:
prefix, id_str = val.split('_')
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 prefix == 'r': # Role
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
elif prefix == 'u': # User
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
self.storage.save_config(interaction.guild_id, self.config)
@ -2894,13 +3196,40 @@ class TicketCommands(commands.Cog):
else:
cat_id = parts[1] if len(parts) > 1 else None
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)
modal = TicketPriorityModal(self.bot, config, category)
# 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] catv2_ routing error: {e}", exc_info=True)
@ -2933,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()
@ -3258,18 +3661,93 @@ class TicketCommands(commands.Cog):
print(f"[Ticket] Error registering views for guild {guild_id}: {e}")
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")
async def ticket_main(self, inter: disnake.ApplicationCommandInteraction):
"""Ouvre le panel des tickets"""
await inter.response.defer(ephemeral=True)
config = self.storage.load_config(inter.guild.id)
view = TicketPanelView(self.bot, config, self.storage)
await inter.edit_original_response(components=view.get_components_v2())
await inter.response.send_message(components=view.get_components_v2(), view=view, ephemeral=True)
@commands.slash_command(name="ticketpanel", description="Envoie le panel de création de tickets dans un canal")
@commands.has_permissions(administrator=True)

View file

@ -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,7 +79,10 @@ 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"""
return {
@ -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,7 +200,8 @@ 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:
return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]
@ -214,9 +227,10 @@ 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
def from_dict(cls, data: Dict[str, Any]) -> 'TicketData':
messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])]
@ -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", {})
)

View file

@ -107,39 +107,46 @@ class AdvancedLogger:
def check_previous_roles(self, member_id: int) -> Optional[Dict]:
"""Check if member has previous role data when rejoining"""
member_log_file = f"{self.member_logs_dir}/{member_id}.json"
if not os.path.exists(member_log_file):
return None
try:
logs = []
with open(member_log_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
return None
# Handle legacy JSON list format
if content.startswith('['):
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
# 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 lines
line = line.strip(',')
if not line:
continue
try:
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:
for line in content.splitlines():
line = line.strip()
if not line: continue
# Strip trailing/leading commas in case of semi-corrupt legacy conversion
line = line.strip(',')
try:
logs.append(json.loads(line))
except json.JSONDecodeError:
continue # Skip corrupt lines
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: