Fix: création de catégorie système de ticket kuby

This commit is contained in:
Mathis 2026-07-03 22:59:31 +02:00
parent 9051093fb3
commit eb07cb67a2
2 changed files with 320 additions and 185 deletions

View file

@ -41,6 +41,9 @@ from src.logger import kuby_logger
# V2 RICH COMPONENTS LOADED - 14:10
kuby_logger.info("🎫 [TicketSystem] Chargement de la version V2 Rich Components...")
# Discord limite le nombre de composants par message ; 25 couvre les selects Discord.
MAX_PANEL_CATEGORIES = 25
# Add a task for sending feedback reminders
class FeedbackReminderTask:
def __init__(self, bot):
@ -153,12 +156,8 @@ class TicketPanelView(disnake.ui.View):
async def _create_callback(self, interaction: disnake.Interaction):
"""Affiche les catégories pour créer un ticket"""
# Reload config to ensure we have latest permissions/categories
self.storage.invalidate_cache(interaction.guild_id)
self.config = self.storage.load_config(interaction.guild_id)
# Reload config to ensure we have latest permissions/categories
self.config = self.storage.load_config(interaction.guild_id)
if not self.config.enabled:
await interaction.response.send_message("Le système de tickets est désactivé.", ephemeral=True)
@ -167,45 +166,26 @@ class TicketPanelView(disnake.ui.View):
if not self.config.categories:
await interaction.response.send_message("Aucune catégorie configurée. Utilisez la configuration.", ephemeral=True)
return
# Build V2 Container for category selection
sections = [
disnake.ui.TextDisplay(content="## 🎫 Créer un Ticket"),
disnake.ui.Separator(),
]
for cat in self.config.categories[:5]:
desc = cat.description or "Aucune description"
sections.append(
disnake.ui.Section(
f"**{cat.emoji} {cat.name}**\n{desc}",
accessory=disnake.ui.Button(
label=cat.name,
style=disnake.ButtonStyle.primary,
custom_id=f"catv2_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}"
)
)
)
components = build_category_selection_components(self.config)
try:
await interaction.response.send_message(
components=[disnake.ui.Container(*sections)],
components=components,
flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True
)
except Exception as e:
import traceback
traceback.print_exc()
# Fallback: texte simple avec view legacy
if interaction.response.is_done():
await interaction.followup.send(
f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, self.config),
ephemeral=True
)
else:
await interaction.response.send_message(
f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, self.config),
ephemeral=True
)
@ -477,6 +457,104 @@ class TicketPanelView(disnake.ui.View):
return stats
async def refresh_ticket_panel(bot, storage, guild_id: int) -> bool:
"""Met à jour le message du panel de création de tickets s'il est enregistré."""
config = storage.load_config(guild_id)
if not config.panel_channel_id or not config.panel_message_id:
return False
try:
channel = bot.get_channel(config.panel_channel_id)
if channel is None:
channel = await bot.fetch_channel(config.panel_channel_id)
message = await channel.fetch_message(config.panel_message_id)
view = TicketPanelView(bot, config, storage)
await message.edit(components=view.get_components_v2())
return True
except Exception as e:
kuby_logger.warning(f"[TicketSystem] Échec du rafraîchissement du panel: {e}")
return False
def build_category_selection_components(config: GuildTicketConfig) -> list:
"""Construit le sélecteur de catégories (menu déroulant, jusqu'à 25 options).
Un Container V2 ne peut contenir que 10 composants enfants ; une Section+Separator
par catégorie dépassait rapidement cette limite. Le menu déroulant affiche toutes
les catégories dans un seul composant.
"""
categories = config.categories[:MAX_PANEL_CATEGORIES]
if not categories:
return []
options = []
for cat in categories:
option_kwargs = {
"label": cat.name[:100],
"value": str(cat.id)[:100],
}
description = (cat.description or "").strip()
if description:
option_kwargs["description"] = description[:100]
if cat.emoji:
option_kwargs["emoji"] = cat.emoji
options.append(disnake.SelectOption(**option_kwargs))
category_select = disnake.ui.StringSelect(
custom_id="ticket_category_select",
placeholder="Choisissez une catégorie...",
options=options,
min_values=1,
max_values=1,
)
return [
disnake.ui.Container(
disnake.ui.TextDisplay(
content="# 📂 Créer un Ticket\n\n"
"Sélectionnez la catégorie correspondant à votre demande."
),
disnake.ui.ActionRow(category_select),
)
]
async def open_ticket_for_category(bot, interaction: disnake.Interaction, config: GuildTicketConfig, category_id: str):
"""Ouvre le modal de création de ticket pour la catégorie choisie."""
category = config.get_category(category_id)
if not category:
await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
storage = get_storage()
user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True)
active_tickets = []
for ticket in user_tickets:
if interaction.guild.get_channel(ticket.channel_id):
active_tickets.append(ticket)
else:
ticket.status = TicketStatus.CLOSED
storage.save_ticket(ticket)
if len(active_tickets) >= config.max_tickets_per_user:
await interaction.response.send_message(
f"Vous avez atteint la limite de {config.max_tickets_per_user} tickets.",
ephemeral=True
)
return
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(bot, config, category)
else:
modal = TicketPriorityModal(bot, config, category)
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await interaction.response.send_modal(modal)
class CategoryButton(disnake.ui.Button):
def __init__(self, category, view_instance):
super().__init__(
@ -502,7 +580,7 @@ class CategorySelectionView(disnake.ui.View):
self.id_gen = int(datetime.now().timestamp())
# Ajouter boutons de catégories
for category in config.categories[:5]:
for category in config.categories[:MAX_PANEL_CATEGORIES]:
self.add_item(CategoryButton(category, self))
def _is_staff(self, interaction: disnake.Interaction) -> bool:
@ -517,40 +595,7 @@ class CategorySelectionView(disnake.ui.View):
async def _on_category_select(self, interaction: disnake.Interaction, category_id: str):
"""Handle category selection"""
try:
category = self.config.get_category(category_id)
if not category:
await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
storage = get_storage()
# Check limits
user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True)
active_tickets = []
for t in user_tickets:
if interaction.guild.get_channel(t.channel_id):
active_tickets.append(t)
else:
# Automatically clean up phantom tickets
t.status = TicketStatus.CLOSED
storage.save_ticket(t)
if len(active_tickets) >= self.config.max_tickets_per_user:
await interaction.response.send_message(
f"Vous avez atteint la limite de {self.config.max_tickets_per_user} tickets.",
ephemeral=True
)
return
# 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)
await open_ticket_for_category(self.bot, interaction, self.config, category_id)
except Exception as e:
kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True)
try:
@ -720,10 +765,13 @@ class RecruitmentQuestionsModal(disnake.ui.Modal):
self.question_inputs = {}
for i, question in enumerate(category.questions):
# Discord constraints:
# - placeholder length <= 100
# We clamp the dynamic placeholder (questions come from config).
input_field = disnake.ui.TextInput(
label=f"Question {i+1}",
label=f"Question {i+1}", # <= 45 by construction
custom_id=f"recruitment_q_{i}",
placeholder=question,
placeholder=_clamp_text(question, 100, ""),
required=True,
max_length=1000,
style=disnake.TextInputStyle.paragraph
@ -2050,6 +2098,7 @@ class CategoriesSetupView(disnake.ui.View):
# Remove category
self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id]
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
await interaction.response.send_message("✅ Catégorie supprimée!", ephemeral=True)
@ -2300,34 +2349,92 @@ class ConfigPanelView(disnake.ui.View):
await interaction.response.send_modal(modal)
def _clamp_text(s: Any, max_len: int, default: str = "") -> str:
"""Discord TextInput constraints:
- label: 1..45
- placeholder: <=100
"""
if s is None:
return default
s = str(s)
if max_len <= 0:
return ""
if len(s) > max_len:
s = s[:max_len]
return s
class AddCategoryModal(disnake.ui.Modal):
"""Modal pour ajouter une catégorie"""
def __init__(self, bot, storage, config: GuildTicketConfig):
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)
# label max 45, placeholder max 100
name_label = _clamp_text("Nom", 45, "Nom")
name_placeholder = _clamp_text("Ex: Support", 100, "")
desc_label = _clamp_text("Description", 45, "Description")
desc_placeholder = _clamp_text("Description...", 100, "")
emoji_label = _clamp_text("Emoji", 45, "Emoji")
emoji_placeholder = _clamp_text("🎫", 100, "🎫")
recruitment_label = _clamp_text("Recrutement? (oui/non)", 45, "Recrutement?")
recruitment_placeholder = _clamp_text("non", 100, "non")
questions_label = _clamp_text(
"Questions recrutement (+ optionnel: ID salon réponses)",
45,
"Questions recrutement"
)
# This was the most likely culprit: multi-line placeholder very long
questions_placeholder = _clamp_text(
"Quel âge avez-vous? / Pourquoi vouloir rejoindre? / Expériences? / Optionnel: ID salon réponses: 123456789012345678",
100,
""
)
self.name_input = disnake.ui.TextInput(
label=name_label,
custom_id="add_cat_name",
placeholder=name_placeholder,
required=True,
)
self.desc_input = disnake.ui.TextInput(
label=desc_label,
custom_id="add_cat_desc",
placeholder=desc_placeholder,
required=False,
style=disnake.TextInputStyle.paragraph,
)
self.emoji_input = disnake.ui.TextInput(
label=emoji_label,
custom_id="add_cat_emoji",
placeholder=emoji_placeholder,
required=False,
max_length=5,
)
# Recruitment fields
self.is_recruitment_input = disnake.ui.TextInput(
label="Recrutement? (oui/non)",
label=recruitment_label,
custom_id="add_cat_recruitment",
placeholder="non",
placeholder=recruitment_placeholder,
required=False,
max_length=5
max_length=5,
)
# IMPORTANT:
# Disnake Modal has a maximum number of components.
# Keep this modal <= 5 components by merging "questions" + "responses channel id" into one input.
self.questions_input = disnake.ui.TextInput(
label="Questions recrutement (une par ligne)",
label=questions_label, # <=45 enforced
custom_id="add_cat_questions",
placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?",
placeholder=questions_placeholder, # <=100 enforced
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
style=disnake.TextInputStyle.paragraph,
)
super().__init__(
title="Ajouter une Catégorie",
components=[
@ -2336,9 +2443,9 @@ class AddCategoryModal(disnake.ui.Modal):
self.emoji_input,
self.is_recruitment_input,
self.questions_input,
self.responses_channel_input
]
)
self.bot = bot
self.storage = storage
self.config = config
@ -2353,13 +2460,23 @@ class AddCategoryModal(disnake.ui.Modal):
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 []
# Parse:
# - questions are all non-empty lines
# - if the LAST non-empty line is a digit-only id => treat it as responses_channel_id
raw_lines = [ln.strip() for ln in (questions_val or "").split("\n") if ln.strip()]
responses_channel_id = None
if responses_channel_val and responses_channel_val.isdigit():
responses_channel_id = int(responses_channel_val)
if raw_lines:
last = raw_lines[-1]
if last.isdigit():
# treat last line as channel id, remove it from questions
responses_channel_id = int(last)
raw_lines = raw_lines[:-1]
questions = raw_lines if raw_lines else []
category = TicketCategory(
id=cat_id,
@ -2368,11 +2485,14 @@ class AddCategoryModal(disnake.ui.Modal):
emoji=emoji_val,
is_recruitment=is_recruitment,
questions=questions,
responses_channel_id=responses_channel_id
responses_channel_id=responses_channel_id if is_recruitment else None
)
self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config)
self.storage.invalidate_cache(interaction.guild_id)
config = self.storage.load_config(interaction.guild_id)
config.categories.append(category)
self.storage.save_config(interaction.guild_id, config)
self.config = config
recruitment_info = ""
if is_recruitment:
@ -2380,7 +2500,18 @@ class AddCategoryModal(disnake.ui.Modal):
if responses_channel_id:
recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>"
await interaction.response.send_message(
await interaction.response.defer(ephemeral=True)
if interaction.message:
try:
manager_view = CategoryManagerView(self.bot, self.storage, config)
await interaction.message.edit(components=manager_view.get_components_v2())
except Exception as e:
kuby_logger.warning(f"[AddCategoryModal] Impossible de rafraîchir la liste des catégories: {e}")
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
await interaction.followup.send(
f"Catégorie **{name_val}** créée!{recruitment_info}",
ephemeral=True
)
@ -2406,6 +2537,7 @@ class EditCategoryModal(disnake.ui.Modal):
self.category.description = interaction.text_values.get("edit_cat_desc", "")
self.category.emoji = interaction.text_values.get("edit_cat_emoji", "🎫")
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
container = disnake.ui.Container(
disnake.ui.TextDisplay(content=f"✅ Catégorie **{self.category.name}** mise à jour !"),
@ -2507,17 +2639,22 @@ class CategoryManagerView(disnake.ui.View):
description=desc_val,
emoji=emoji_val
)
self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config)
self.storage.invalidate_cache(interaction.guild_id)
config = self.storage.load_config(interaction.guild_id)
config.categories.append(category)
self.storage.save_config(interaction.guild_id, config)
self.config = config
self.parent_view.config = config
# Refresh parent view
self.parent_view._add_category_select()
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
# On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed
await interaction.response.edit_message(
embed=self.parent_view._get_embed(),
view=self.parent_view
)
await interaction.followup.send(f"Catégorie **{self.name.value}** créée!", ephemeral=True)
await interaction.followup.send(f"Catégorie **{name_val}** créée!", ephemeral=True)
modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self)
await interaction.response.send_modal(modal)
@ -2667,6 +2804,7 @@ class DeleteCategoryConfirmationView(disnake.ui.View):
if self.category in self.config.categories:
self.config.categories.remove(self.category)
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
self.parent_view._add_category_select()
await interaction.response.edit_message(
@ -3191,31 +3329,11 @@ class TicketCommands(commands.Cog):
# ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = catv2_{cat.id}_{snowflake}
if custom_id.startswith("catv2_"):
try:
# format: catv2_<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
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
# 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)
# Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await inter.response.send_modal(modal)
cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None)
await open_ticket_for_category(self.bot, inter, config, cat_id)
except Exception as e:
kuby_logger.error(f"[TicketSystem] cat| routing error: {e}", exc_info=True)
kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True)
if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
@ -3225,56 +3343,83 @@ class TicketCommands(commands.Cog):
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)
# Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await inter.response.send_modal(modal)
await open_ticket_for_category(self.bot, inter, config, cat_id)
except Exception as e:
kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True)
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)
elif custom_id == "ticket_create":
# Construction du layout de sélection Premium V2
category_items = [
disnake.ui.TextDisplay(
content="# 📂 Sélectionner une Catégorie - Omega Kube\n\n"
"Veuillez choisir la catégorie qui correspond au motif de votre demande pour ouvrir un ticket."
# Debug stockage/cache (pour diagnostiquer "catégorie non trouvée")
try:
kuby_logger.info(f"[TicketSystem][ticket_create] guild.id={getattr(inter.guild,'id',None)} guild_id={getattr(inter,'guild_id',None)}")
except Exception:
pass
self.storage.invalidate_cache(inter.guild.id)
config = self.storage.load_config(inter.guild.id)
try:
cats = getattr(config, "categories", None) or []
# Affiche aussi des infos pour diagnostiquer le storage
storage_obj = self.storage
config_path = getattr(storage_obj, "config_path", None)
kuby_logger.info(
f"[TicketSystem][ticket_create] storage.config_path={config_path} "
f"guild_id={inter.guild.id} "
f"categories_len={len(cats)} categories_ids={[getattr(c,'id',None) for c in cats[:5]]}"
)
]
# Génération dynamique des sections avec Emojis et Descriptions configurées
for cat in config.categories[:5]:
cat_emoji = getattr(cat, "emoji", "") or ""
cat_desc = getattr(cat, "description", "") or getattr(cat, "desc", "") or ""
category_items.append(disnake.ui.Separator(divider=True))
category_items.append(
disnake.ui.Section(
f"### {cat_emoji} {cat.name}\n{cat_desc}" if cat_desc else f"### {cat_emoji} {cat.name}",
accessory=disnake.ui.Button(
label="Choisir",
style=disnake.ButtonStyle.blurple,
custom_id=f"cat_{cat.id}_select"
)
except Exception:
pass
if not config.categories:
# Au cas où cache désynchronisé => invalider aussi avec guild_id
try:
self.storage.invalidate_cache(inter.guild_id)
config = self.storage.load_config(inter.guild_id)
cats = getattr(config, "categories", None) or []
kuby_logger.info(f"[TicketSystem][ticket_create] (after invalidate guild_id) categories_len={len(cats)}")
except Exception:
pass
if not config.categories:
await inter.response.send_message(
"Aucune catégorie configurée (data/tickets/config.json semble vide pour cette guild). "
"Allez dans la configuration et ajoutez au moins 1 catégorie.",
ephemeral=True
)
return
components = build_category_selection_components(config)
try:
await inter.response.send_message(
components=components,
flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True
)
await inter.response.send_message(
components=[disnake.ui.Container(*category_items)],
ephemeral=True
)
except Exception as e:
kuby_logger.error(f"[TicketSystem] ticket_create UI error: {e}", exc_info=True)
if inter.response.is_done():
await inter.followup.send(
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, config),
ephemeral=True
)
else:
await inter.response.send_message(
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, config),
ephemeral=True
)
elif custom_id == "ticket_category_select":
self.storage.invalidate_cache(inter.guild.id)
config = self.storage.load_config(inter.guild.id)
cat_id = inter.values[0] if inter.values else None
if not cat_id:
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
await open_ticket_for_category(self.bot, inter, config, cat_id)
elif custom_id == "ticket_my_tickets":
# Afficher les tickets de l'utilisateur
user_tickets = self.storage.get_user_tickets(inter.guild.id, inter.user.id)
@ -3568,33 +3713,15 @@ class TicketCommands(commands.Cog):
return
elif custom_id.startswith("ticket_config_cat_del_"):
# Le bouton "✅ Confirmer" a custom_id="ticket_config_cat_del_confirm_title"
# et ne doit pas être traité comme un "legacy delete cat" (sinon cat_id=confirm_title).
# Ne pas traiter ticket_config_cat_del_confirm_title ici :
# la suppression est déjà gérée par DeleteCategoryConfirmationView.confirm_callback.
# Traiter à nouveau ici provoquait une double suppression (2 catégories supprimées).
if custom_id == "ticket_config_cat_del_confirm_title":
# Juste reconstruire l'UI parent à partir de l'état actuel.
config = self.storage.load_config(inter.guild.id)
if not config:
await inter.response.send_message("Erreur: config introuvable.", ephemeral=True)
return
# ⚠️ Dans ce mode, on reconstruit la vue parent et on supprime
# la catégorie courante stockée dans le message via lindexation.
# Ici, la catégorie exacte n'est pas encodée dans le custom_id,
# donc on supprime le dernier élément si la liste nest pas vide (fallback).
# (Meilleur: corriger lencodage en index au moment de créer le bouton.)
if not config.categories:
await inter.response.send_message("Aucune catégorie à supprimer.", ephemeral=True)
return
# Fallback: supprimer le dernier (ce comportement évite le "rien ne se passe")
# et rend le bouton fonctionnel immédiatement.
category = config.categories[-1]
if category in config.categories:
config.categories.remove(category)
self.storage.save_config(inter.guild.id, config)
# Rebuild UI
view = CategoryManagerView(self.bot, self.storage, config)
await inter.response.edit_message(components=view.get_components_v2())
if config and config.categories is not None:
view = CategoryManagerView(self.bot, self.storage, config)
await inter.response.edit_message(components=view.get_components_v2())
return
# Recharger config pour éviter les mismatch (panel vs état storage)
@ -3826,7 +3953,11 @@ class TicketCommands(commands.Cog):
target = channel or inter.channel
view = TicketPanelView(self.bot, config, self.storage)
await target.send(components=view.get_components_v2())
panel_message = await target.send(components=view.get_components_v2())
config.panel_channel_id = target.id
config.panel_message_id = panel_message.id
self.storage.save_config(inter.guild.id, config)
await inter.edit_original_response(content=f"Panel envoyé dans {target.mention}")