diff --git a/commandes/blacklist.py b/commandes/blacklist.py
index 7df329e..cc53e5e 100755
--- a/commandes/blacklist.py
+++ b/commandes/blacklist.py
@@ -32,6 +32,26 @@ class Blacklist(commands.Cog):
def cog_unload(self):
self.check_ban_expiry.cancel()
+
+ async def cog_load(self):
+ """Enregistrer les vues persistantes au démarrage"""
+ await self._register_persistent_views()
+
+ async def _register_persistent_views(self):
+ """Enregistre les vues pour les tickets ouverts"""
+ try:
+ tickets = load_json(TICKETS_FILE, {})
+ for user_id_str, data in tickets.items():
+ user_id = int(user_id_str)
+ # Enregistrer le bouton d'ouverture de ticket
+ view = TicketButton(user_id)
+ self.bot.add_view(view)
+ # Enregistrer le bouton de fermeture
+ close_view = CloseTicketView(user_id)
+ self.bot.add_view(close_view)
+ print(f"[Blacklist] {len(tickets)} vues de tickets enregistrées")
+ except Exception as e:
+ print(f"[Blacklist] Erreur lors de l'enregistrement des vues: {e}")
def is_agent(self, user_id: int):
agents = load_json(AGENTS_FILE, {"agents": []})
@@ -180,7 +200,7 @@ class TicketButton(discord.ui.View):
super().__init__(timeout=None)
self.user_id = user_id
- @discord.ui.button(label="📩 Ouvrir une demande de révision", style=discord.ButtonStyle.primary)
+ @discord.ui.button(label="📩 Ouvrir une demande de révision", style=discord.ButtonStyle.primary, custom_id="blacklist_open_ticket")
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user.id != self.user_id:
return await interaction.response.send_message("❌ Ce bouton ne t’appartient pas.", ephemeral=True)
@@ -219,7 +239,7 @@ class CloseTicketView(discord.ui.View):
super().__init__(timeout=None)
self.user_id = user_id
- @discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger)
+ @discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger, custom_id="blacklist_close_ticket")
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
return await interaction.response.send_message("⛔ Seuls les agents peuvent fermer ce ticket.", ephemeral=True)
@@ -232,7 +252,7 @@ class ConfirmCloseView(discord.ui.View):
super().__init__(timeout=30)
self.user_id = user_id
- @discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success)
+ @discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success, custom_id="blacklist_confirm_close")
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
@@ -270,7 +290,7 @@ class ConfirmCloseView(discord.ui.View):
await interaction.response.send_message("✅ Ticket fermé avec succès.", ephemeral=True)
- @discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary)
+ @discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary, custom_id="blacklist_cancel_close")
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_message("❌ Fermeture annulée.", ephemeral=True)
diff --git a/commandes/modules_security/rules.py b/commandes/modules_security/rules.py
index e123653..6290dd9 100644
--- a/commandes/modules_security/rules.py
+++ b/commandes/modules_security/rules.py
@@ -387,6 +387,10 @@ class RulesCog(commands.Cog):
self.bot = bot
self.persistent_views_added = False
+ async def cog_load(self):
+ """Called when cog is loaded - setup persistent views"""
+ await self.setup_persistent_views()
+
async def setup_persistent_views(self):
"""Configure les views persistantes pour les règlements existants"""
if self.persistent_views_added:
@@ -406,6 +410,7 @@ class RulesCog(commands.Cog):
# Ajouter la view persistente
view = RulesAcceptButton(int(guild_id_str), settings["rules_accept_role_id"])
self.bot.add_view(view)
+ print(f"[Rules] View registered for guild {guild_id_str}")
self.persistent_views_added = True
except (json.JSONDecodeError, FileNotFoundError):
@@ -414,7 +419,7 @@ class RulesCog(commands.Cog):
@commands.Cog.listener()
async def on_ready(self):
"""Appelé quand le bot est prêt"""
- await self.setup_persistent_views()
+ print("🎫 Module Règles chargé avec succès!")
async def send_rules_message(self, channel: discord.TextChannel, settings: dict) -> Optional[discord.Message]:
"""Envoie le message du règlement dans un salon"""
diff --git a/commandes/ticket.py b/commandes/ticket.py
index b48994f..2c0ea12 100644
--- a/commandes/ticket.py
+++ b/commandes/ticket.py
@@ -1,207 +1,23 @@
-import discord
-from discord.ext import commands, tasks
-from discord.ui import View, Button, Modal, TextInput
-import json
+"""
+Legacy Ticket Module
+====================
+This file now serves as a compatibility wrapper for the new modular ticket system.
+The main implementation has been moved to the 'commandes/ticket' package.
+
+To use the new modular system, import from the ticket package:
+ from commandes.ticket import Ticket
+
+Or use the setup function directly:
+ from commandes.ticket import setup
+"""
+import sys
import os
-from datetime import datetime
-import asyncio
-# --- Fonctions de gestion de données ---
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-def load_config():
- """Charge la configuration des tickets"""
- path = "data/tickets/config.json"
- if not os.path.exists("data/tickets"):
- os.makedirs("data/tickets")
- if not os.path.exists(path):
- with open(path, "w") as f:
- json.dump({}, f, indent=4)
- with open(path, "r") as f:
- return json.load(f)
+# Import from the new modular ticket system
+from ticket import setup
-def save_config(config):
- """Sauvegarde la configuration des tickets"""
- path = "data/tickets/config.json"
- with open(path, "w", encoding="utf-8") as f:
- json.dump(config, f, indent=4, ensure_ascii=False)
+__all__ = ["setup"]
-# --- Vues (Interactions) ---
-
-class TicketView(View):
- def __init__(self, bot, config):
- super().__init__(timeout=None)
- self.bot = bot
- self.config = config
-
- @discord.ui.button(label="Créer un ticket", style=discord.ButtonStyle.blurple, custom_id="create_ticket")
- async def create_ticket(self, interaction: discord.Interaction, button: Button):
- await interaction.response.defer(ephemeral=True)
- guild_id = str(interaction.guild_id)
-
- if guild_id not in self.config:
- return await interaction.followup.send("Le système n'est pas configuré.", ephemeral=True)
-
- guild_config = self.config[guild_id]
-
- # Vérifier si l'utilisateur a déjà un ticket
- for channel in interaction.guild.text_channels:
- if channel.name.startswith(f"ticket-{interaction.user.id}"):
- return await interaction.followup.send(f"Vous avez déjà un ticket : {channel.mention}", ephemeral=True)
-
- # Configuration des permissions
- category = interaction.guild.get_channel(int(guild_config["category_id"]))
- overwrites = {
- interaction.guild.default_role: discord.PermissionOverwrite(read_messages=False),
- interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True, attach_files=True),
- interaction.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
- }
-
- for role_id in guild_config["staff_roles"]:
- role = interaction.guild.get_role(int(role_id))
- if role:
- overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
-
- # Création du salon
- channel_name = f"ticket-{interaction.user.id}-{datetime.now().strftime('%m%d%H%M')}"
- ticket_channel = await interaction.guild.create_text_channel(
- name=channel_name,
- category=category,
- overwrites=overwrites,
- topic=f"Ticket de {interaction.user} ({interaction.user.id})"
- )
-
- embed = discord.Embed(
- title="Nouveau Ticket",
- description=guild_config["ticket_message"],
- color=discord.Color.blue(),
- timestamp=datetime.now()
- )
- embed.set_author(name=interaction.user.display_name, icon_url=interaction.user.display_avatar.url)
-
- await ticket_channel.send(embed=embed, view=TicketCloseView(self.bot, self.config))
-
- # Logs
- if guild_config.get("log_channel"):
- log_ch = self.bot.get_channel(int(guild_config["log_channel"]))
- if log_ch:
- log_embed = discord.Embed(title="Ticket Créé", color=discord.Color.green())
- log_embed.add_field(name="Auteur", value=interaction.user.mention)
- log_embed.add_field(name="Salon", value=ticket_channel.mention)
- await log_ch.send(embed=log_embed)
-
- await interaction.followup.send(f"Ticket créé : {ticket_channel.mention}", ephemeral=True)
-
-class TicketCloseView(View):
- def __init__(self, bot, config):
- super().__init__(timeout=None)
- self.bot = bot
- self.config = config
-
- @discord.ui.button(label="Fermer le ticket", style=discord.ButtonStyle.red, custom_id="close_ticket")
- async def close_ticket(self, interaction: discord.Interaction, button: Button):
- await interaction.response.defer(ephemeral=True)
- guild_config = self.config.get(str(interaction.guild_id))
-
- # Modifier permissions (bloquer l'écriture)
- overwrites = interaction.channel.overwrites
- for target in overwrites:
- if isinstance(target, discord.Member) and target != interaction.guild.me:
- overwrites[target] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
-
- await interaction.channel.edit(name=f"closed-{interaction.channel.name}", overwrites=overwrites)
-
- embed = discord.Embed(title="Ticket Fermé", description=f"Fermé par {interaction.user.mention}", color=discord.Color.red())
- await interaction.channel.send(embed=embed, view=TicketReopenView(self.bot, self.config))
- await interaction.followup.send("Ticket fermé.", ephemeral=True)
-
-class TicketReopenView(View):
- def __init__(self, bot, config):
- super().__init__(timeout=None)
- self.bot = bot
- self.config = config
-
- @discord.ui.button(label="Rouvrir", style=discord.ButtonStyle.green, custom_id="reopen_ticket")
- async def reopen(self, interaction: discord.Interaction, button: Button):
- await interaction.response.defer(ephemeral=True)
- new_name = interaction.channel.name.replace("closed-", "")
- await interaction.channel.edit(name=new_name)
- await interaction.channel.send(f"Ticket rouvert par {interaction.user.mention}", view=TicketCloseView(self.bot, self.config))
-
- @discord.ui.button(label="Supprimer", style=discord.ButtonStyle.grey, custom_id="delete_ticket")
- async def delete(self, interaction: discord.Interaction, button: Button):
- await interaction.response.send_message("Suppression dans 3s...")
- await asyncio.sleep(3)
- await interaction.channel.delete()
-
-# --- Modale de Configuration ---
-
-class TicketConfigModal(Modal):
- def __init__(self, bot, config, guild_id):
- super().__init__(title="Configuration Tickets")
- self.bot = bot
- self.config = config
- self.guild_id = str(guild_id)
-
- self.category_id = TextInput(label="ID Catégorie", placeholder="Ex: 123456789", required=True)
- self.staff_roles = TextInput(label="IDs Rôles Staff (virgule)", placeholder="ID1, ID2", required=True)
- self.log_channel = TextInput(label="ID Salon Logs", required=False)
- self.ticket_msg = TextInput(label="Message Ticket", style=discord.TextStyle.paragraph, required=True)
- self.welcome_msg = TextInput(label="Message Panel", required=True)
-
- for item in [self.category_id, self.staff_roles, self.log_channel, self.ticket_msg, self.welcome_msg]:
- self.add_item(item)
-
- async def on_submit(self, interaction: discord.Interaction):
- role_ids = [r.strip() for r in self.staff_roles.value.split(",")]
- self.config[self.guild_id] = {
- "category_id": self.category_id.value,
- "staff_roles": role_ids,
- "log_channel": self.log_channel.value,
- "ticket_message": self.ticket_msg.value,
- "welcome_message": self.welcome_msg.value
- }
- save_config(self.config)
- await interaction.response.send_message("Configuration sauvegardée !", ephemeral=True)
-
-# --- Le Cog Principal ---
-
-class Ticket(commands.Cog):
- def __init__(self, bot):
- self.bot = bot
- self.config = load_config()
-
- @commands.Cog.listener()
- async def on_ready(self):
- # Enregistrer les vues persistantes (pour qu'elles marchent après reboot)
- self.bot.add_view(TicketView(self.bot, self.config))
- self.bot.add_view(TicketCloseView(self.bot, self.config))
- self.bot.add_view(TicketReopenView(self.bot, self.config))
- print("Module Ticket prêt")
-
- @commands.hybrid_group(name="ticket")
- @commands.has_permissions(administrator=True)
- async def ticket(self, ctx):
- if ctx.invoked_subcommand is None:
- await ctx.send("Utilisez `/ticket setup` ou `/ticket panel`", ephemeral=True)
-
- @ticket.command(name="setup")
- async def setup_cmd(self, ctx):
- await ctx.interaction.response.send_modal(TicketConfigModal(self.bot, self.config, ctx.guild.id))
-
- @ticket.command(name="panel")
- async def panel_cmd(self, ctx, channel: discord.TextChannel = None):
- guild_id = str(ctx.guild.id)
- if guild_id not in self.config:
- return await ctx.send("Configurez d'abord le système via `/ticket setup`.")
-
- target = channel or ctx.channel
- embed = discord.Embed(
- title="Support",
- description=self.config[guild_id]["welcome_message"],
- color=discord.Color.blue()
- )
- await target.send(embed=embed, view=TicketView(self.bot, self.config))
- await ctx.send("Panel envoyé !", ephemeral=True)
-
-async def setup(bot):
- await bot.add_cog(Ticket(bot))
\ No newline at end of file
diff --git a/commandes/ticket/TODO.md b/commandes/ticket/TODO.md
new file mode 100644
index 0000000..bec0d09
--- /dev/null
+++ b/commandes/ticket/TODO.md
@@ -0,0 +1,130 @@
+# 🎫 Ticket System - TODO & Documentation
+
+## Vue d'ensemble
+Système de tickets modulaire et complet pour Discord bot.
+
+## Structure du projet
+```
+commandes/ticket/
+├── __init__.py # Commande principale (le cog)
+├── ticket.py # Wrapper de compatibilité (ancien fichier)
+├── views/ # Interfaces utilisateurs
+│ ├── creation.py # Création de tickets
+│ └── admin.py # Panneau d'admin
+├── data/ # Données et stockage
+│ ├── models.py # Modèles de données
+│ └── storage.py # Gestion du stockage JSON
+├── utils/ # Utilitaires
+│ ├── transcript.py # Génération de transcriptions
+│ └── formatters.py # Formatage des embeds
+└── TODO.md # Documentation
+```
+
+## ⚠️ Vues Persistantes - Important
+
+Pour que les boutons fonctionnent après un redémarrage du bot, toutes les vues doivent:
+1. Utiliser `timeout=None` dans le constructeur
+2. Être enregistrées avec `bot.add_view(view)` dans `cog_load()`
+
+### Vues persistantes configurées:
+- ✅ `TicketCreationView` - timeout=None
+- ✅ `TicketManagementView` - timeout=None
+- ✅ `SurveyView` - timeout=None
+- ✅ `AdminPanelView` - timeout=None
+- ✅ `ConfigPanelView` - timeout=None
+- ✅ `BulkCloseView` - timeout=None
+- ✅ `RolesManagementView` - timeout=None
+
+### Enregistrement des vues
+Les vues sont enregistrées dans `cog_load()` et aussi périodiquement toutes les 30 secondes:
+
+```python
+async def cog_load(self):
+ await self._register_all_views()
+
+async def _register_all_views(self):
+ for guild_id in self.storage.get_all_guild_ids():
+ config = self.storage.load_config(guild_id)
+ if config and config.enabled:
+ self.bot.add_view(TicketCreationView(self.bot, config))
+ self.bot.add_view(TicketManagementView(...))
+ # etc.
+```
+
+## Commandes
+
+| Commande | Description |
+|----------|-------------|
+| `/ticket setup` | Configure le système (admin) |
+| `/ticket panel [channel]` | Crée le panneau de création |
+| `/ticket info [channel]` | Infos sur un ticket |
+| `/ticket list [user]` | Liste des tickets |
+| `/ticket stats` | Statistiques |
+| `/ticket admin` | Panneau d'admin |
+| `/ticket transcript [channel]` | Génère une transcription |
+| `/ticket category add\|remove\|list` | Gère les catégories |
+| `/ticket close [channel] [reason]` | Ferme un ticket |
+| `/ticket reopen [channel]` | Rouvre un ticket |
+
+## Configuration
+
+### Modèles de données
+- **TicketData** - Données d'un ticket
+- **TicketCategory** - Catégorie de ticket
+- **GuildTicketConfig** - Configuration du serveur
+- **TicketStatus** - Statuts: OPEN, CLAIMED, CLOSED, ARCHIVED
+- **Priority** - Priorités: LOW, NORMAL, HIGH, URGENT
+
+### Stockage
+Les données sont stockées dans `data/tickets/`:
+- `config_guild_id.json` - Configuration du serveur
+- `tickets.json` - Tous les tickets
+
+## Fonctionnalités
+
+### Création de tickets
+1. L'utilisateur clique sur une catégorie
+2. Sélectionne la priorité (si activé)
+3. Entre la raison (si requis)
+4. Le salon est créé automatiquement
+
+### Gestion des tickets
+- **Fermer** - Ferme avec raison optionnelle
+- **Réclamer** - Reserve le ticket à un staff
+- **Priorité** - Change la priorité
+- **Transcript** - Génère une transcription
+
+### Système de survey
+Enquête de satisfaction 1-5 étoiles après fermeture
+
+## Problèmes connus et solutions
+
+### Boutons ne fonctionnent pas après redémarrage
+**Cause:** Views non persistantes (timeout défini) ou non enregistrées
+**Solution:**
+1. Vérifier que `timeout=None` est défini
+2. Vérifier que `bot.add_view()` est appelé dans `cog_load()`
+3. Redémarrer le bot après avoir envoyé le panneau
+
+### Transcript non généré
+**Cause:** Historique trop long ou permissions manquantes
+**Solution:** Le transcript limite à 1000 messages
+
+## Dépannage
+
+```bash
+# Tester si les vues sont enregistrées
+# Les boutons doivent répondre même après un redémarrage
+
+# Vérifier les logs pour les erreurs
+tail -f logs/kuby.log
+```
+
+## À faire (Backlog)
+
+- [ ] Ajouter support pour transcripts PDF
+- [ ] Implémenter système de tags pour tickets
+- [ ] Ajouter intégration avec webhooks
+- [ ] Support multi-langue
+- [ ] Interface web pour administration
+
diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py
new file mode 100644
index 0000000..5774e42
--- /dev/null
+++ b/commandes/ticket/__init__.py
@@ -0,0 +1,661 @@
+"""
+Modular Ticket System
+=====================
+A complete, beautiful, and modular ticket system for Discord bots.
+
+Features:
+- Multiple ticket categories with custom settings
+- Priority levels (Low/Normal/High/Urgent)
+- Claim system for staff
+- Transcript generation (TXT, JSON, HTML)
+- Satisfaction surveys
+- Statistics and reporting
+- Bulk management
+- Auto-close inactive tickets
+- Rich embeds and beautiful UI
+"""
+import os
+import sys
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+import discord
+from discord.ext import commands, tasks
+from datetime import datetime
+from typing import Optional
+
+from .data.storage import get_storage
+from .data.models import GuildTicketConfig, TicketCategory, TicketStatus, Priority
+from .utils.formatters import TicketEmbedFormatter
+from .utils.transcript import get_transcript_generator
+from .views.creation import (
+ TicketCreationView,
+ TicketManagementView,
+ SurveyView,
+)
+from .views.admin import (
+ AdminPanelView,
+ ConfigPanelView,
+ RolesManagementView,
+)
+
+
+class Ticket(commands.Cog):
+ """
+ Main ticket cog with all functionality.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.storage = get_storage()
+ self._register_views.start()
+ self._auto_close_check.start()
+
+ def cog_unload(self):
+ self._register_views.cancel()
+ self._auto_close_check.cancel()
+
+ async def cog_load(self):
+ """Register persistent views when cog is loaded"""
+ await self._register_all_views()
+
+ async def _register_all_views(self):
+ """Register all persistent views for all configured guilds"""
+ try:
+ for guild_id in self.storage.get_all_guild_ids():
+ try:
+ config = self.storage.load_config(guild_id)
+ if config and config.enabled:
+ # Register creation view (with categories)
+ creation_view = TicketCreationView(self.bot, config)
+ self.bot.add_view(creation_view)
+
+ # Register management view (no config needed for persistence)
+ management_view = TicketManagementView(self.bot, self.storage, config)
+ self.bot.add_view(management_view)
+
+ # Register admin views
+ admin_view = AdminPanelView(self.bot, config)
+ self.bot.add_view(admin_view)
+
+ config_view = ConfigPanelView(self.bot, config)
+ self.bot.add_view(config_view)
+
+ roles_view = RolesManagementView(self.bot, config)
+ self.bot.add_view(roles_view)
+
+ print(f"[Ticket] Views registered for guild {guild_id}")
+ except Exception as e:
+ print(f"[Ticket] Error registering views for guild {guild_id}: {e}")
+ except Exception as e:
+ print(f"[Ticket] Error in _register_all_views: {e}")
+
+ @tasks.loop(seconds=30)
+ async def _register_views(self):
+ """Register persistent views periodically"""
+ await self._register_all_views()
+
+ @tasks.loop(minutes=5)
+ async def _auto_close_check(self):
+ """Check for tickets to auto-close"""
+ try:
+ for guild_id in self.storage.get_all_guild_ids():
+ config = self.storage.load_config(guild_id)
+ if not config.auto_close_enabled:
+ continue
+
+ auto_close_days = config.auto_close_days or 7
+ auto_close_threshold = datetime.now().timestamp() - (auto_close_days * 86400)
+
+ tickets = self.storage.get_guild_tickets(guild_id, TicketStatus.OPEN)
+ for ticket in tickets:
+ if ticket.created_at.timestamp() < auto_close_threshold:
+ # Auto-close ticket
+ ticket.status = TicketStatus.CLOSED
+ ticket.closed_at = datetime.now()
+ self.storage.save_ticket(ticket)
+
+ # Log if configured
+ if config.log_channel_id:
+ log_channel = self.bot.get_guild(guild_id).get_channel(config.log_channel_id)
+ if log_channel:
+ await log_channel.send(
+ f"🔒 Ticket auto-fermé (inactif depuis {auto_close_days} jours)"
+ )
+ except Exception as e:
+ print(f"[Ticket] Auto-close error: {e}")
+
+ @commands.Cog.listener()
+ async def on_ready(self):
+ print("🎫 Module Ticket chargé avec succès!")
+
+ # --- Main Commands ---
+
+ @commands.hybrid_group(name="ticket", aliases=["tickets", "ticketing"])
+ async def ticket_group(self, ctx):
+ """Système de tickets complet"""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ # --- Setup Commands ---
+
+ @ticket_group.command(name="setup", aliases=["configurer"])
+ @commands.has_permissions(administrator=True)
+ async def ticket_setup(self, ctx):
+ """Configurer le système de tickets"""
+ await ctx.defer(ephemeral=True)
+
+ config = self.storage.load_config(ctx.guild.id)
+
+ # Send setup panel
+ embed = discord.Embed(
+ title="⚙️ Configuration des Tickets",
+ description="Bienvenue dans le panneau de configuration des tickets!",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="📁 Catégories", value=str(len(config.categories)), inline=True)
+ embed.add_field(name="👥 Rôles Staff", value=str(len(config.staff_roles)), inline=True)
+ embed.add_field(name="📊 Canal Logs", value=f"<#{config.log_channel_id}>" if config.log_channel_id else "Non configuré", inline=True)
+
+ view = TicketSetupView(self.bot, self.storage, config)
+ await ctx.send(embed=embed, view=view, ephemeral=True)
+
+ @ticket_group.command(name="panel", aliases=["panneau"])
+ async def ticket_panel(self, ctx, channel: discord.TextChannel = None):
+ """Créer un panneau de création de tickets"""
+ await ctx.defer(ephemeral=True)
+
+ config = self.storage.load_config(ctx.guild.id)
+
+ if not config.enabled:
+ await ctx.send("Le système de tickets est désactivé.", ephemeral=True)
+ return
+
+ if not config.categories:
+ await ctx.send("Aucune catégorie configurée. Utilisez `/ticket setup` d'abord.", ephemeral=True)
+ return
+
+ target = channel or ctx.channel
+
+ embed = TicketEmbedFormatter.panel_embed(config, ctx.guild.name)
+ view = TicketCreationView(self.bot, config)
+
+ await target.send(embed=embed, view=view)
+ await ctx.send(f"Panneau envoyé dans {target.mention}", ephemeral=True)
+
+ # --- Info Commands ---
+
+ @ticket_group.command(name="info", aliases=["informations"])
+ async def ticket_info(self, ctx, channel: discord.TextChannel = None):
+ """Voir les informations d'un ticket"""
+ await ctx.defer(ephemeral=True)
+
+ target = channel or ctx.channel
+
+ storage = get_storage()
+ ticket = storage.load_ticket(target.id)
+
+ if not ticket:
+ await ctx.send("Ce salon n'est pas un ticket.", ephemeral=True)
+ return
+
+ user = self.bot.get_user(ticket.user_id)
+ category = storage.load_config(ctx.guild.id).get_category(ticket.category_id)
+
+ embed = TicketEmbedFormatter.ticket_info_embed(ticket, user, category)
+
+ await ctx.send(embed=embed, ephemeral=True)
+
+ @ticket_group.command(name="list", aliases=["liste"])
+ async def ticket_list(self, ctx, user: discord.Member = None):
+ """Liste des tickets"""
+ await ctx.defer(ephemeral=True)
+
+ storage = get_storage()
+
+ if user:
+ tickets = storage.get_user_tickets(ctx.guild.id, user.id, open_only=False)
+ else:
+ tickets = storage.get_guild_tickets(ctx.guild.id)
+
+ if not tickets:
+ await ctx.send("Aucun ticket trouvé.", ephemeral=True)
+ return
+
+ embed = TicketEmbedFormatter.ticket_list_embed(tickets, user)
+
+ await ctx.send(embed=embed, ephemeral=True)
+
+ # --- Stats Command ---
+
+ @ticket_group.command(name="stats", aliases=["statistiques"])
+ async def ticket_stats(self, ctx):
+ """Voir les statistiques des tickets"""
+ await ctx.defer(ephemeral=True)
+
+ config = self.storage.load_config(ctx.guild.id)
+
+ stats = self._calculate_stats(ctx.guild.id)
+ embed = TicketEmbedFormatter.stats_embed(stats, ctx.guild.name)
+
+ await ctx.send(embed=embed, ephemeral=True)
+
+ def _calculate_stats(self, guild_id):
+ """Calculate ticket statistics"""
+ from .data.models import TicketStats
+
+ storage = get_storage()
+ tickets = storage.get_guild_tickets(guild_id)
+
+ stats = TicketStats()
+ stats.total_tickets = len(tickets)
+
+ total_duration = 0
+ closed_count = 0
+ rating_sum = 0
+ rating_count = 0
+
+ for ticket in tickets:
+ if ticket.status == TicketStatus.OPEN:
+ stats.open_tickets += 1
+ elif ticket.status == TicketStatus.CLAIMED:
+ stats.claimed_tickets += 1
+ stats.open_tickets += 1
+ elif ticket.status == TicketStatus.CLOSED:
+ stats.closed_tickets += 1
+ closed_count += 1
+
+ stats.tickets_by_category[ticket.category_id] = \
+ stats.tickets_by_category.get(ticket.category_id, 0) + 1
+
+ stats.tickets_by_priority[ticket.priority.value] = \
+ stats.tickets_by_priority.get(ticket.priority.value, 0) + 1
+
+ if ticket.duration_minutes:
+ total_duration += ticket.duration_minutes
+
+ if ticket.rating:
+ rating_sum += ticket.rating
+ rating_count += 1
+
+ if closed_count > 0:
+ stats.average_duration_minutes = total_duration / closed_count
+
+ if rating_count > 0:
+ stats.average_rating = rating_sum / rating_count
+
+ return stats
+
+ # --- Admin Commands ---
+
+ @ticket_group.command(name="admin", aliases=["administration"])
+ @commands.has_permissions(administrator=True)
+ async def ticket_admin(self, ctx):
+ """Panneau d'administration des tickets"""
+ await ctx.defer(ephemeral=True)
+
+ config = self.storage.load_config(ctx.guild.id)
+ view = AdminPanelView(self.bot, config)
+
+ embed = discord.Embed(
+ title="🎛️ Administration des Tickets",
+ description="Gérez votre système de tickets ici:",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="📊 Statistiques", value="Voir les stats", inline=True)
+ embed.add_field(name="🔴 Fermeture Massive", value="Fermer plusieurs tickets", inline=True)
+ embed.add_field(name="⚙️ Configuration", value="Configurer le système", inline=True)
+ embed.add_field(name="🧹 Nettoyer", value="Supprimer les vieux tickets", inline=True)
+
+ await ctx.send(embed=embed, view=view, ephemeral=True)
+
+ @ticket_group.command(name="transcript", aliases=["transcription"])
+ async def ticket_transcript(self, ctx, channel: discord.TextChannel = None):
+ """Générer un transcript pour un ticket"""
+ await ctx.defer(ephemeral=True)
+
+ target = channel or ctx.channel
+
+ storage = get_storage()
+ ticket = storage.load_ticket(target.id)
+
+ if not ticket:
+ await ctx.send("Ce salon n'est pas un ticket.", ephemeral=True)
+ return
+
+ # Collect messages
+ messages = []
+ async for msg in target.history(limit=1000, oldest_first=True):
+ if msg.author.bot:
+ continue
+ messages.append({
+ "id": str(msg.id),
+ "author_id": msg.author.id,
+ "author_name": str(msg.author),
+ "content": msg.content,
+ "timestamp": msg.created_at.isoformat(),
+ "attachments": [a.url for a in msg.attachments],
+ "is_staff": self._is_staff(msg.author, ctx.guild)
+ })
+
+ # Generate transcript
+ transcript_gen = get_transcript_generator()
+ filepath = transcript_gen.save_transcript(ticket, messages, format="html")
+
+ if filepath:
+ await ctx.send(f"✅ Transcript sauvegardé: `{filepath}`", ephemeral=True)
+ else:
+ await ctx.send("❌ Erreur lors de la génération.", ephemeral=True)
+
+ def _is_staff(self, member, guild) -> bool:
+ """Check if member is staff"""
+ config = self.storage.load_config(guild.id)
+ for role_id in config.staff_roles:
+ role = guild.get_role(int(role_id))
+ if role and role in member.roles:
+ return True
+ return False
+
+ # --- Category Management ---
+
+ @ticket_group.command(name="category", aliases=["categorie"])
+ @commands.has_permissions(administrator=True)
+ async def ticket_category(self, ctx, action: str, category_name: str = None):
+ """Gérer les catégories de tickets (add/remove/list)"""
+ await ctx.defer(ephemeral=True)
+
+ config = self.storage.load_config(ctx.guild.id)
+
+ if action == "list":
+ if not config.categories:
+ await ctx.send("Aucune catégorie configurée.", ephemeral=True)
+ return
+
+ embed = discord.Embed(
+ title="📁 Catégories de Tickets",
+ color=discord.Color.blue()
+ )
+
+ for cat in config.categories:
+ embed.add_field(
+ name=f"{cat.emoji} {cat.name}",
+ value=f"ID: `{cat.id}`\n{cat.description or 'Aucune description'}",
+ inline=False
+ )
+
+ await ctx.send(embed=embed, ephemeral=True)
+
+ elif action == "add":
+ if not category_name:
+ await ctx.send("Nom de catégorie requis.", ephemeral=True)
+ return
+
+ import uuid
+ cat_id = str(uuid.uuid4())[:8]
+
+ category = TicketCategory(
+ id=cat_id,
+ name=category_name,
+ description="Nouvelle catégorie",
+ emoji="🎫"
+ )
+
+ config.categories.append(category)
+ self.storage.save_config(ctx.guild.id, config)
+
+ await ctx.send(f"Catégorie **{category_name}** créée!", ephemeral=True)
+
+ elif action == "remove":
+ if not category_name:
+ await ctx.send("Nom de catégorie requis.", ephemeral=True)
+ return
+
+ config.categories = [c for c in config.categories if c.name.lower() != category_name.lower()]
+ self.storage.save_config(ctx.guild.id, config)
+
+ await ctx.send(f"Catégorie **{category_name}** supprimée.", ephemeral=True)
+
+ else:
+ await ctx.send("Action invalide. Utilisez: list, add, remove", ephemeral=True)
+
+ # --- Close/Reopen ---
+
+ @ticket_group.command(name="close", aliases=["fermer"])
+ async def ticket_close(self, ctx, channel: discord.TextChannel = None, *, reason: str = None):
+ """Fermer un ticket"""
+ await ctx.defer(ephemeral=True)
+
+ target = channel or ctx.channel
+
+ storage = get_storage()
+ ticket = storage.load_ticket(target.id)
+
+ if not ticket:
+ await ctx.send("Ce salon n'est pas un ticket.", ephemeral=True)
+ return
+
+ # Check permissions
+ can_close = (
+ ctx.author.id == ticket.user_id or
+ self._is_staff(ctx.author, ctx.guild)
+ )
+
+ if not can_close:
+ await ctx.send("Vous ne pouvez pas fermer ce ticket.", ephemeral=True)
+ return
+
+ # Close ticket
+ ticket.status = TicketStatus.CLOSED
+ ticket.closed_at = datetime.now()
+ storage.save_ticket(ticket)
+
+ # Update channel
+ overwrites = target.overwrites.copy()
+ for target_perm, perms in overwrites.items():
+ if isinstance(target_perm, discord.Member) and target_perm.id != ctx.guild.me.id:
+ overwrites[target_perm] = discord.PermissionOverwrite(
+ read_messages=True,
+ send_messages=False
+ )
+
+ await target.edit(
+ name=f"closed-{target.name}",
+ overwrites=overwrites
+ )
+
+ embed = TicketEmbedFormatter.ticket_closed(ticket, ctx.author, ticket.duration_minutes, reason)
+ await target.send(embed=embed)
+
+ await ctx.send("Ticket fermé!", ephemeral=True)
+
+ @ticket_group.command(name="reopen", aliases=["rouvrir"])
+ async def ticket_reopen(self, ctx, channel: discord.TextChannel = None):
+ """Rouvrir un ticket fermé"""
+ await ctx.defer(ephemeral=True)
+
+ target = channel or ctx.channel
+
+ storage = get_storage()
+ ticket = storage.load_ticket(target.id)
+
+ if not ticket:
+ await ctx.send("Ce salon n'est pas un ticket.", ephemeral=True)
+ return
+
+ if ticket.status != TicketStatus.CLOSED:
+ await ctx.send("Ce ticket n'est pas fermé.", ephemeral=True)
+ return
+
+ # Check permissions
+ can_reopen = (
+ ctx.author.id == ticket.user_id or
+ self._is_staff(ctx.author, ctx.guild)
+ )
+
+ if not can_reopen:
+ await ctx.send("Vous ne pouvez pas rouvrir ce ticket.", ephemeral=True)
+ return
+
+ # Reopen ticket
+ ticket.status = TicketStatus.OPEN
+ storage.save_ticket(ticket)
+
+ # Update channel name
+ new_name = target.name.replace("closed-", "")
+ await target.edit(name=new_name)
+
+ embed = TicketEmbedFormatter.ticket_reopened(ticket, ctx.author)
+ await target.send(embed=embed)
+
+ await ctx.send("Ticket rouvert!", ephemeral=True)
+
+
+class TicketSetupView(discord.ui.View):
+ """View for initial ticket setup"""
+
+ def __init__(self, bot, storage, config: GuildTicketConfig):
+ super().__init__(timeout=None) # Persistent view
+ self.bot = bot
+ self.storage = storage
+ self.config = config
+
+ @discord.ui.button(label="➕ Ajouter Catégorie", style=discord.ButtonStyle.blurple, custom_id="ticket_setup_add_category")
+ async def add_category(self, interaction: discord.Interaction, button: discord.ui.Button):
+ modal = AddCategoryModal(self.bot, self.storage, self.config)
+ await interaction.response.send_modal(modal)
+
+ @discord.ui.button(label="👥 Configurer Rôles Staff", style=discord.ButtonStyle.secondary, custom_id="ticket_setup_config_roles")
+ async def config_roles(self, interaction: discord.Interaction, button: discord.ui.Button):
+ from .views.admin import RolesManagementView
+
+ view = RolesManagementView(self.bot, self.config)
+
+ role_mentions = []
+ for role_id in self.config.staff_roles:
+ role = interaction.guild.get_role(int(role_id))
+ if role:
+ role_mentions.append(role.mention)
+
+ embed = discord.Embed(
+ title="👥 Gestion des Rôles Staff",
+ description="Sélectionnez les rôles qui auront accès aux tickets:",
+ color=discord.Color.blue()
+ )
+ embed.add_field(
+ name="Rôles actuels",
+ value=", ".join(role_mentions) if role_mentions else "Aucun",
+ inline=False
+ )
+
+ await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
+
+ @discord.ui.button(label="📊 Définir Canal Logs", style=discord.ButtonStyle.secondary, custom_id="ticket_setup_config_logs")
+ async def config_logs(self, interaction: discord.Interaction, button: discord.ui.Button):
+ modal = LogChannelModal(self.storage, self.config)
+ await interaction.response.send_modal(modal)
+
+ @discord.ui.button(label="✅ Système Activé", style=discord.ButtonStyle.green, custom_id="ticket_setup_toggle_system")
+ async def enable_system(self, interaction: discord.Interaction, button: discord.ui.Button):
+ self.config.enabled = not self.config.enabled
+ self.storage.save_config(interaction.guild.id, self.config)
+
+ if self.config.enabled:
+ button.style = discord.ButtonStyle.green
+ button.label = "✅ Système Activé"
+ status_msg = "Système de tickets activé!"
+ else:
+ button.style = discord.ButtonStyle.red
+ button.label = "❌ Système Désactivé"
+ status_msg = "Système de tickets désactivé!"
+
+ await interaction.response.edit_message(view=self)
+ await interaction.followup.send(status_msg, ephemeral=True)
+
+
+class AddCategoryModal(discord.ui.Modal):
+ """Modal for adding a category"""
+
+ def __init__(self, bot, storage, config: GuildTicketConfig):
+ super().__init__(title="➕ Nouvelle Catégorie")
+ self.bot = bot
+ self.storage = storage
+ self.config = config
+
+ self.name = discord.ui.TextInput(
+ label="Nom",
+ placeholder="Ex: Support",
+ required=True
+ )
+ self.add_item(self.name)
+
+ self.description = discord.ui.TextInput(
+ label="Description",
+ placeholder="Description de la catégorie...",
+ required=False,
+ style=discord.TextStyle.paragraph
+ )
+ self.add_item(self.description)
+
+ self.emoji = discord.ui.TextInput(
+ label="Emoji",
+ placeholder="🎫",
+ required=False,
+ max_length=5
+ )
+ self.add_item(self.emoji)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ import uuid
+
+ cat_id = str(uuid.uuid4())[:8]
+
+ category = TicketCategory(
+ id=cat_id,
+ name=self.name.value,
+ description=self.description.value or "",
+ emoji=self.emoji.value or "🎫"
+ )
+
+ self.config.categories.append(category)
+
+ if not self.config.default_category:
+ self.config.default_category = cat_id
+
+ self.storage.save_config(interaction.guild.id, self.config)
+
+ await interaction.response.send_message(
+ f"Catégorie **{self.name.value}** créée avec succès!",
+ ephemeral=True
+ )
+
+
+class LogChannelModal(discord.ui.Modal):
+ """Modal for setting log channel"""
+
+ def __init__(self, storage, config: GuildTicketConfig):
+ super().__init__(title="📊 Canal Logs")
+ self.storage = storage
+ self.config = config
+
+ self.channel_id = discord.ui.TextInput(
+ label="ID du Canal",
+ placeholder="Ex: 123456789012345678",
+ required=True
+ )
+ self.add_item(self.channel_id)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ try:
+ channel_id = int(self.channel_id.value)
+ self.config.log_channel_id = channel_id
+ self.storage.save_config(interaction.guild.id, self.config)
+ await interaction.response.send_message(f"Canal logs défini: <#{channel_id}>", ephemeral=True)
+ except ValueError:
+ await interaction.response.send_message("ID de canal invalide.", ephemeral=True)
+
+
+async def setup(bot):
+ """Setup function for the cog"""
+ await bot.add_cog(Ticket(bot))
+
diff --git a/commandes/ticket/data/__init__.py b/commandes/ticket/data/__init__.py
new file mode 100644
index 0000000..1c5f789
--- /dev/null
+++ b/commandes/ticket/data/__init__.py
@@ -0,0 +1,29 @@
+"""
+Ticket Data Module
+==================
+Data management for the ticket system.
+"""
+from .models import (
+ TicketStatus,
+ Priority,
+ TicketCategory,
+ TicketMessage,
+ TicketData,
+ TicketStats,
+ GuildTicketConfig
+)
+from .storage import TicketStorage, get_storage, reset_storage
+
+__all__ = [
+ "TicketStatus",
+ "Priority",
+ "TicketCategory",
+ "TicketMessage",
+ "TicketData",
+ "TicketStats",
+ "GuildTicketConfig",
+ "TicketStorage",
+ "get_storage",
+ "reset_storage",
+]
+
diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py
new file mode 100644
index 0000000..605551f
--- /dev/null
+++ b/commandes/ticket/data/models.py
@@ -0,0 +1,353 @@
+"""
+Ticket Data Models
+==================
+Data models for the modular ticket system.
+"""
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Optional, List, Dict, Any
+from enum import Enum
+
+
+class TicketStatus(Enum):
+ """Ticket status enumeration"""
+ OPEN = "open"
+ CLAIMED = "claimed"
+ CLOSED = "closed"
+ ARCHIVED = "archived"
+
+
+class Priority(Enum):
+ """Ticket priority levels"""
+ LOW = "low"
+ NORMAL = "normal"
+ HIGH = "high"
+ URGENT = "urgent"
+
+
+class TicketCategory:
+ """
+ Represents a ticket category with customizable settings.
+
+ Attributes:
+ id: Unique identifier for the category
+ name: Display name
+ description: Description shown in panel
+ emoji: Emoji icon for the category
+ color: Embed color (hex)
+ staff_roles: List of role IDs who can access this category
+ welcome_message: Custom welcome message for this category
+ require_reason: Whether to require a reason when creating
+ max_tickets_per_user: Maximum tickets per user in this category
+ auto_close_days: Days before auto-closing inactive tickets
+ priority_enabled: Whether priority selection is enabled
+ allow_claims: Whether staff can claim tickets
+ survey_enabled: Whether satisfaction survey is enabled
+ """
+
+ def __init__(
+ self,
+ id: str,
+ name: str,
+ description: str = "",
+ emoji: str = "🎫",
+ color: str = "#3498db",
+ staff_roles: List[str] = None,
+ welcome_message: str = None,
+ require_reason: bool = False,
+ max_tickets_per_user: int = 5,
+ auto_close_days: int = 7,
+ priority_enabled: bool = True,
+ allow_claims: bool = True,
+ survey_enabled: bool = True
+ ):
+ self.id = id
+ self.name = name
+ self.description = description
+ self.emoji = emoji
+ self.color = color
+ self.staff_roles = staff_roles or []
+ self.welcome_message = welcome_message or "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu."
+ self.require_reason = require_reason
+ self.max_tickets_per_user = max_tickets_per_user
+ self.auto_close_days = auto_close_days
+ self.priority_enabled = priority_enabled
+ self.allow_claims = allow_claims
+ self.survey_enabled = survey_enabled
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert category to dictionary for storage"""
+ return {
+ "id": self.id,
+ "name": self.name,
+ "description": self.description,
+ "emoji": self.emoji,
+ "color": self.color,
+ "staff_roles": self.staff_roles,
+ "welcome_message": self.welcome_message,
+ "require_reason": self.require_reason,
+ "max_tickets_per_user": self.max_tickets_per_user,
+ "auto_close_days": self.auto_close_days,
+ "priority_enabled": self.priority_enabled,
+ "allow_claims": self.allow_claims,
+ "survey_enabled": self.survey_enabled
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'TicketCategory':
+ """Create category from dictionary"""
+ return cls(
+ id=data.get("id", ""),
+ name=data.get("name", ""),
+ description=data.get("description", ""),
+ emoji=data.get("emoji", "🎫"),
+ color=data.get("color", "#3498db"),
+ staff_roles=data.get("staff_roles", []),
+ welcome_message=data.get("welcome_message"),
+ require_reason=data.get("require_reason", False),
+ max_tickets_per_user=data.get("max_tickets_per_user", 5),
+ auto_close_days=data.get("auto_close_days", 7),
+ priority_enabled=data.get("priority_enabled", True),
+ allow_claims=data.get("allow_claims", True),
+ survey_enabled=data.get("survey_enabled", True)
+ )
+
+
+@dataclass
+class TicketMessage:
+ """Represents a message in a ticket"""
+ id: str
+ author_id: int
+ author_name: str
+ content: str
+ timestamp: datetime
+ attachments: List[str] = field(default_factory=list)
+ is_staff: bool = False
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "id": self.id,
+ "author_id": self.author_id,
+ "author_name": self.author_name,
+ "content": self.content,
+ "timestamp": self.timestamp.isoformat(),
+ "attachments": self.attachments,
+ "is_staff": self.is_staff
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'TicketMessage':
+ return cls(
+ id=data.get("id", ""),
+ author_id=data.get("author_id", 0),
+ author_name=data.get("author_name", ""),
+ content=data.get("content", ""),
+ timestamp=datetime.fromisoformat(data.get("timestamp", datetime.now().isoformat())),
+ attachments=data.get("attachments", []),
+ is_staff=data.get("is_staff", False)
+ )
+
+
+@dataclass
+class TicketData:
+ """
+ Main ticket data structure.
+
+ Attributes:
+ channel_id: Discord channel ID
+ guild_id: Discord guild ID
+ user_id: User who created the ticket
+ category_id: Ticket category
+ status: Current ticket status
+ priority: Ticket priority level
+ created_at: Creation timestamp
+ closed_at: Closing timestamp
+ claimed_by: Staff member who claimed the ticket
+ messages: List of messages in the ticket
+ reason: Reason for creating the ticket
+ transcript_path: Path to generated transcript
+ rating: Satisfaction rating (1-5)
+ feedback: User feedback text
+ """
+
+ channel_id: int
+ guild_id: int
+ user_id: int
+ category_id: str
+ status: TicketStatus = TicketStatus.OPEN
+ priority: Priority = Priority.NORMAL
+ created_at: datetime = field(default_factory=datetime.now)
+ closed_at: Optional[datetime] = None
+ claimed_by: Optional[int] = None
+ messages: List[TicketMessage] = field(default_factory=list)
+ reason: str = ""
+ transcript_path: Optional[str] = None
+ rating: Optional[int] = None
+ feedback: Optional[str] = None
+
+ @property
+ def is_open(self) -> bool:
+ return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]
+
+ @property
+ def duration_minutes(self) -> Optional[float]:
+ """Get ticket duration in minutes"""
+ end = self.closed_at or datetime.now()
+ return (end - self.created_at).total_seconds() / 60
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "channel_id": self.channel_id,
+ "guild_id": self.guild_id,
+ "user_id": self.user_id,
+ "category_id": self.category_id,
+ "status": self.status.value,
+ "priority": self.priority.value,
+ "created_at": self.created_at.isoformat(),
+ "closed_at": self.closed_at.isoformat() if self.closed_at else None,
+ "claimed_by": self.claimed_by,
+ "messages": [m.to_dict() for m in self.messages],
+ "reason": self.reason,
+ "transcript_path": self.transcript_path,
+ "rating": self.rating,
+ "feedback": self.feedback
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'TicketData':
+ messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])]
+ return cls(
+ channel_id=data.get("channel_id", 0),
+ guild_id=data.get("guild_id", 0),
+ user_id=data.get("user_id", 0),
+ category_id=data.get("category_id", ""),
+ status=TicketStatus(data.get("status", "open")),
+ priority=Priority(data.get("priority", "normal")),
+ created_at=datetime.fromisoformat(data.get("created_at", datetime.now().isoformat())),
+ closed_at=datetime.fromisoformat(data["closed_at"]) if data.get("closed_at") else None,
+ claimed_by=data.get("claimed_by"),
+ messages=messages,
+ reason=data.get("reason", ""),
+ transcript_path=data.get("transcript_path"),
+ rating=data.get("rating"),
+ feedback=data.get("feedback")
+ )
+
+
+@dataclass
+class TicketStats:
+ """Statistics for a ticket system"""
+ total_tickets: int = 0
+ open_tickets: int = 0
+ closed_tickets: int = 0
+ claimed_tickets: int = 0
+ average_duration_minutes: float = 0
+ average_rating: float = 0
+ tickets_by_category: Dict[str, int] = field(default_factory=dict)
+ tickets_by_priority: Dict[str, int] = field(default_factory=dict)
+ tickets_by_day: Dict[str, int] = field(default_factory=dict)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "total_tickets": self.total_tickets,
+ "open_tickets": self.open_tickets,
+ "closed_tickets": self.closed_tickets,
+ "claimed_tickets": self.claimed_tickets,
+ "average_duration_minutes": self.average_duration_minutes,
+ "average_rating": self.average_rating,
+ "tickets_by_category": self.tickets_by_category,
+ "tickets_by_priority": self.tickets_by_priority,
+ "tickets_by_day": self.tickets_by_day
+ }
+
+
+@dataclass
+class GuildTicketConfig:
+ """
+ Guild-wide ticket configuration.
+
+ Attributes:
+ guild_id: Discord guild ID
+ enabled: Whether the ticket system is enabled
+ categories: List of ticket categories
+ default_category: Default category ID
+ log_channel_id: Channel for ticket logs
+ archive_channel_id: Channel for archived transcripts
+ staff_roles: Global staff roles
+ max_tickets_per_user: Global max tickets per user
+ auto_close_enabled: Whether auto-close is enabled
+ auto_close_days: Days before auto-close
+ transcript_enabled: Whether transcripts are generated
+ claim_enabled: Whether claiming is enabled by default
+ survey_enabled: Whether surveys are enabled by default
+ panel_channel_id: Channel for the ticket panel
+ welcome_enabled: Whether welcome messages are shown
+ priority_enabled: Whether priority selection is enabled
+ """
+
+ guild_id: int
+ enabled: bool = True
+ categories: List[TicketCategory] = field(default_factory=list)
+ default_category: Optional[str] = None
+ log_channel_id: Optional[int] = None
+ archive_channel_id: Optional[int] = None
+ staff_roles: List[str] = field(default_factory=list)
+ max_tickets_per_user: int = 5
+ auto_close_enabled: bool = False
+ auto_close_days: int = 7
+ transcript_enabled: bool = True
+ claim_enabled: bool = True
+ survey_enabled: bool = True
+ panel_channel_id: Optional[int] = None
+ welcome_enabled: bool = True
+ priority_enabled: bool = True
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "guild_id": self.guild_id,
+ "enabled": self.enabled,
+ "categories": [c.to_dict() for c in self.categories],
+ "default_category": self.default_category,
+ "log_channel_id": self.log_channel_id,
+ "archive_channel_id": self.archive_channel_id,
+ "staff_roles": self.staff_roles,
+ "max_tickets_per_user": self.max_tickets_per_user,
+ "auto_close_enabled": self.auto_close_enabled,
+ "auto_close_days": self.auto_close_days,
+ "transcript_enabled": self.transcript_enabled,
+ "claim_enabled": self.claim_enabled,
+ "survey_enabled": self.survey_enabled,
+ "panel_channel_id": self.panel_channel_id,
+ "welcome_enabled": self.welcome_enabled,
+ "priority_enabled": self.priority_enabled
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'GuildTicketConfig':
+ categories = [TicketCategory.from_dict(c) for c in data.get("categories", [])]
+ return cls(
+ guild_id=data.get("guild_id", 0),
+ enabled=data.get("enabled", True),
+ categories=categories,
+ default_category=data.get("default_category"),
+ log_channel_id=data.get("log_channel_id"),
+ archive_channel_id=data.get("archive_channel_id"),
+ staff_roles=data.get("staff_roles", []),
+ max_tickets_per_user=data.get("max_tickets_per_user", 5),
+ auto_close_enabled=data.get("auto_close_enabled", False),
+ auto_close_days=data.get("auto_close_days", 7),
+ transcript_enabled=data.get("transcript_enabled", True),
+ claim_enabled=data.get("claim_enabled", True),
+ survey_enabled=data.get("survey_enabled", True),
+ panel_channel_id=data.get("panel_channel_id"),
+ welcome_enabled=data.get("welcome_enabled", True),
+ priority_enabled=data.get("priority_enabled", True)
+ )
+
+ def get_category(self, category_id: str) -> Optional[TicketCategory]:
+ """Get a category by ID"""
+ for cat in self.categories:
+ if cat.id == category_id:
+ return cat
+ return None
+
diff --git a/commandes/ticket/data/storage.py b/commandes/ticket/data/storage.py
new file mode 100644
index 0000000..5bca179
--- /dev/null
+++ b/commandes/ticket/data/storage.py
@@ -0,0 +1,323 @@
+"""
+Ticket Storage Utility
+======================
+Modular storage system for ticket data with caching support.
+"""
+import json
+import os
+from datetime import datetime
+from typing import Dict, Optional, Any, List
+from threading import Lock
+
+from .models import TicketData, GuildTicketConfig, TicketStatus
+
+
+class TicketStorage:
+ """
+ Thread-safe storage manager for ticket data.
+
+ Features:
+ - Automatic file creation
+ - In-memory caching with write-through
+ - Backup creation before writes
+ - Data validation on load
+ """
+
+ def __init__(self, base_path: str = "data/tickets"):
+ self.base_path = base_path
+ self.config_path = os.path.join(base_path, "config.json")
+ self.tickets_path = os.path.join(base_path, "tickets")
+ self.backup_path = os.path.join(base_path, "backups")
+ self.cache: Dict[str, Any] = {}
+ self.cache_lock = Lock()
+ self._ensure_directories()
+
+ def _ensure_directories(self) -> None:
+ """Create necessary directories"""
+ for path in [self.base_path, self.tickets_path, self.backup_path]:
+ os.makedirs(path, exist_ok=True)
+
+ def _create_backup(self, file_path: str) -> str:
+ """Create a timestamped backup of a file"""
+ if not os.path.exists(file_path):
+ return ""
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = os.path.basename(file_path)
+ backup_file = os.path.join(self.backup_path, f"{timestamp}_{filename}")
+
+ try:
+ with open(file_path, 'r', encoding='utf-8') as src:
+ with open(backup_file, 'w', encoding='utf-8') as dst:
+ dst.write(src.read())
+ return backup_file
+ except Exception:
+ return ""
+
+ def _get_cache_key(self, guild_id: int, key_type: str) -> str:
+ """Generate a cache key"""
+ return f"{guild_id}_{key_type}"
+
+ # --- Config Methods ---
+
+ def load_config(self, guild_id: int) -> GuildTicketConfig:
+ """Load guild ticket configuration"""
+ cache_key = self._get_cache_key(guild_id, "config")
+
+ with self.cache_lock:
+ if cache_key in self.cache:
+ return self.cache[cache_key]
+
+ try:
+ if os.path.exists(self.config_path):
+ with open(self.config_path, 'r', encoding='utf-8') as f:
+ all_configs = json.load(f)
+
+ config_data = all_configs.get(str(guild_id), {})
+ config = GuildTicketConfig.from_dict(config_data)
+ config.guild_id = guild_id
+ else:
+ config = GuildTicketConfig(guild_id=guild_id)
+ except (json.JSONDecodeError, KeyError) as e:
+ print(f"[TicketStorage] Error loading config for guild {guild_id}: {e}")
+ config = GuildTicketConfig(guild_id=guild_id)
+
+ with self.cache_lock:
+ self.cache[cache_key] = config
+
+ return config
+
+ def save_config(self, guild_id: int, config: GuildTicketConfig) -> bool:
+ """Save guild ticket configuration"""
+ cache_key = self._get_cache_key(guild_id, "config")
+
+ try:
+ # Create backup
+ self._create_backup(self.config_path)
+
+ # Load all configs
+ all_configs = {}
+ if os.path.exists(self.config_path):
+ with open(self.config_path, 'r', encoding='utf-8') as f:
+ all_configs = json.load(f)
+
+ # Update config
+ all_configs[str(guild_id)] = config.to_dict()
+
+ # Save
+ with open(self.config_path, 'w', encoding='utf-8') as f:
+ json.dump(all_configs, f, indent=4, ensure_ascii=False)
+
+ # Update cache
+ with self.cache_lock:
+ self.cache[cache_key] = config
+
+ return True
+ except Exception as e:
+ print(f"[TicketStorage] Error saving config for guild {guild_id}: {e}")
+ return False
+
+ # --- Ticket Methods ---
+
+ def get_ticket_path(self, channel_id: int) -> str:
+ """Get the file path for a ticket"""
+ return os.path.join(self.tickets_path, f"{channel_id}.json")
+
+ def load_ticket(self, channel_id: int) -> Optional[TicketData]:
+ """Load a ticket by channel ID"""
+ cache_key = self._get_cache_key(channel_id, "ticket")
+
+ with self.cache_lock:
+ if cache_key in self.cache:
+ return self.cache[cache_key]
+
+ path = self.get_ticket_path(channel_id)
+
+ try:
+ if os.path.exists(path):
+ with open(path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ ticket = TicketData.from_dict(data)
+ else:
+ return None
+ except (json.JSONDecodeError, KeyError) as e:
+ print(f"[TicketStorage] Error loading ticket {channel_id}: {e}")
+ return None
+
+ with self.cache_lock:
+ self.cache[cache_key] = ticket
+
+ return ticket
+
+ def save_ticket(self, ticket: TicketData) -> bool:
+ """Save a ticket"""
+ cache_key = self._get_cache_key(ticket.channel_id, "ticket")
+
+ try:
+ # Create backup
+ self._create_backup(self.get_ticket_path(ticket.channel_id))
+
+ path = self.get_ticket_path(ticket.channel_id)
+
+ with open(path, 'w', encoding='utf-8') as f:
+ json.dump(ticket.to_dict(), f, indent=4, ensure_ascii=False)
+
+ with self.cache_lock:
+ self.cache[cache_key] = ticket
+
+ return True
+ except Exception as e:
+ print(f"[TicketStorage] Error saving ticket {ticket.channel_id}: {e}")
+ return False
+
+ def delete_ticket(self, channel_id: int) -> bool:
+ """Delete a ticket file"""
+ cache_key = self._get_cache_key(channel_id, "ticket")
+ path = self.get_ticket_path(channel_id)
+
+ try:
+ if os.path.exists(path):
+ # Create backup before delete
+ self._create_backup(path)
+ os.remove(path)
+
+ with self.cache_lock:
+ self.cache.pop(cache_key, None)
+
+ return True
+ except Exception as e:
+ print(f"[TicketStorage] Error deleting ticket {channel_id}: {e}")
+ return False
+
+ def get_guild_tickets(self, guild_id: int, status: Optional[TicketStatus] = None) -> List[TicketData]:
+ """Get all tickets for a guild, optionally filtered by status"""
+ tickets = []
+
+ if not os.path.exists(self.tickets_path):
+ return tickets
+
+ for filename in os.listdir(self.tickets_path):
+ if not filename.endswith('.json'):
+ continue
+
+ try:
+ channel_id = int(filename.replace('.json', ''))
+ ticket = self.load_ticket(channel_id)
+
+ if ticket and ticket.guild_id == guild_id:
+ if status is None or ticket.status == status:
+ tickets.append(ticket)
+ except (ValueError, TypeError):
+ continue
+
+ return tickets
+
+ def get_user_tickets(self, guild_id: int, user_id: int, open_only: bool = True) -> List[TicketData]:
+ """Get all tickets for a user"""
+ tickets = self.get_guild_tickets(guild_id)
+ user_tickets = [t for t in tickets if t.user_id == user_id]
+
+ if open_only:
+ user_tickets = [t for t in user_tickets if t.is_open]
+
+ return user_tickets
+
+ def count_user_tickets(self, guild_id: int, user_id: int, category_id: Optional[str] = None) -> int:
+ """Count tickets for a user, optionally in a specific category"""
+ tickets = self.get_user_tickets(guild_id, user_id, open_only=True)
+
+ if category_id:
+ tickets = [t for t in tickets if t.category_id == category_id]
+
+ return len(tickets)
+
+ # --- Bulk Operations ---
+
+ def get_all_guild_ids(self) -> List[int]:
+ """Get all guild IDs with ticket configurations"""
+ if not os.path.exists(self.config_path):
+ return []
+
+ try:
+ with open(self.config_path, 'r', encoding='utf-8') as f:
+ configs = json.load(f)
+ return [int(gid) for gid in configs.keys()]
+ except (json.JSONDecodeError, ValueError):
+ return []
+
+ def cleanup_cache(self, max_size: int = 1000) -> int:
+ """Clear cache if it exceeds max size"""
+ with self.cache_lock:
+ if len(self.cache) <= max_size:
+ return 0
+
+ # Clear all non-config keys (keep recent)
+ removed = 0
+ keys_to_remove = [k for k in self.cache.keys() if not k.endswith("_config")]
+
+ for key in keys_to_remove[:len(keys_to_remove) - max_size]:
+ del self.cache[key]
+ removed += 1
+
+ return removed
+
+ def invalidate_cache(self, guild_id: Optional[int] = None) -> None:
+ """Invalidate cache for a guild or all"""
+ with self.cache_lock:
+ if guild_id is None:
+ self.cache.clear()
+ else:
+ keys_to_remove = [k for k in self.cache.keys() if k.startswith(str(guild_id))]
+ for key in keys_to_remove:
+ del self.cache[key]
+
+ # --- Statistics ---
+
+ def get_storage_stats(self) -> Dict[str, Any]:
+ """Get storage statistics"""
+ stats = {
+ "total_tickets": 0,
+ "total_backups": 0,
+ "config_size_bytes": 0,
+ "cache_size": 0,
+ "tickets_by_status": {}
+ }
+
+ # Count tickets
+ if os.path.exists(self.tickets_path):
+ for filename in os.listdir(self.tickets_path):
+ if filename.endswith('.json'):
+ stats["total_tickets"] += 1
+
+ # Count backups
+ if os.path.exists(self.backup_path):
+ stats["total_backups"] = len(os.listdir(self.backup_path))
+
+ # Config size
+ if os.path.exists(self.config_path):
+ stats["config_size_bytes"] = os.path.getsize(self.config_path)
+
+ # Cache size
+ with self.cache_lock:
+ stats["cache_size"] = len(self.cache)
+
+ return stats
+
+
+# Singleton instance
+_storage_instance: Optional[TicketStorage] = None
+
+
+def get_storage() -> TicketStorage:
+ """Get the singleton storage instance"""
+ global _storage_instance
+ if _storage_instance is None:
+ _storage_instance = TicketStorage()
+ return _storage_instance
+
+
+def reset_storage() -> None:
+ """Reset the storage instance (for testing)"""
+ global _storage_instance
+ _storage_instance = None
+
diff --git a/commandes/ticket/utils/__init__.py b/commandes/ticket/utils/__init__.py
new file mode 100644
index 0000000..0610a88
--- /dev/null
+++ b/commandes/ticket/utils/__init__.py
@@ -0,0 +1,15 @@
+"""
+Ticket Utils Module
+===================
+Utility functions for the ticket system.
+"""
+from .transcript import TranscriptGenerator, get_transcript_generator
+from .formatters import TicketEmbedFormatter, TicketMessageFormatter
+
+__all__ = [
+ "TranscriptGenerator",
+ "get_transcript_generator",
+ "TicketEmbedFormatter",
+ "TicketMessageFormatter",
+]
+
diff --git a/commandes/ticket/utils/formatters.py b/commandes/ticket/utils/formatters.py
new file mode 100644
index 0000000..b09dd12
--- /dev/null
+++ b/commandes/ticket/utils/formatters.py
@@ -0,0 +1,453 @@
+"""
+Ticket Formatters
+=================
+Beautiful embed and message formatters for the ticket system.
+"""
+from datetime import datetime
+from typing import Optional, List, Dict, Any
+from discord import Embed, Color, User, Member, Role
+
+from ..data.models import (
+ TicketData,
+ TicketCategory,
+ TicketStats,
+ GuildTicketConfig,
+ TicketStatus,
+ Priority
+)
+
+
+class TicketEmbedFormatter:
+ """
+ Creates beautiful, consistent embeds for the ticket system.
+
+ Features:
+ - Color-coded by priority
+ - Consistent styling
+ - Rich information display
+ - Translatable text
+ """
+
+ # Priority colors
+ PRIORITY_COLORS = {
+ Priority.LOW: Color.green(),
+ Priority.NORMAL: Color.blue(),
+ Priority.HIGH: Color.orange(),
+ Priority.URGENT: Color.red()
+ }
+
+ # Status emojis
+ STATUS_EMOJIS = {
+ TicketStatus.OPEN: "🟢",
+ TicketStatus.CLAIMED: "🟡",
+ TicketStatus.CLOSED: "🔴",
+ TicketStatus.ARCHIVED: "⚫"
+ }
+
+ # Priority emojis
+ PRIORITY_EMOJIS = {
+ Priority.LOW: "🟢",
+ Priority.NORMAL: "🔵",
+ Priority.HIGH: "🟠",
+ Priority.URGENT: "🔴"
+ }
+
+ @classmethod
+ def get_priority_color(cls, priority: Priority) -> Color:
+ """Get embed color based on priority"""
+ return cls.PRIORITY_COLORS.get(priority, Color.blue())
+
+ @classmethod
+ def format_status(cls, status: TicketStatus) -> str:
+ """Get formatted status with emoji"""
+ emoji = cls.STATUS_EMOJIS.get(status, "⚪")
+ status_names = {
+ TicketStatus.OPEN: "Ouvert",
+ TicketStatus.CLAIMED: "Réclamé",
+ TicketStatus.CLOSED: "Fermé",
+ TicketStatus.ARCHIVED: "Archivé"
+ }
+ return f"{emoji} {status_names.get(status, status.value)}"
+
+ @classmethod
+ def format_priority(cls, priority: Priority) -> str:
+ """Get formatted priority with emoji"""
+ emoji = cls.PRIORITY_EMOJIS.get(priority, "⚪")
+ priority_names = {
+ Priority.LOW: "Basse",
+ Priority.NORMAL: "Normale",
+ Priority.HIGH: "Haute",
+ Priority.URGENT: "Urgente"
+ }
+ return f"{emoji} {priority_names.get(priority, priority.value)}"
+
+ # --- Ticket Creation Embeds ---
+
+ @classmethod
+ def ticket_created(
+ cls,
+ ticket: TicketData,
+ user: User,
+ category: Optional[TicketCategory] = None,
+ guild_name: str = "Serveur"
+ ) -> Embed:
+ """Embed for when a ticket is created"""
+ embed = Embed(
+ title="🎫 Nouveau Ticket Créé",
+ description=category.welcome_message if category else "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu.",
+ color=cls.get_priority_color(ticket.priority),
+ timestamp=datetime.now()
+ )
+
+ embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
+
+ # Add fields
+ embed.add_field(name="📌 Ticket ID", value=f"`{ticket.channel_id}`", inline=True)
+ embed.add_field(name="👤 Utilisateur", value=user.mention, inline=True)
+ embed.add_field(name="📁 Catégorie", value=category.name if category else ticket.category_id, inline=True)
+ embed.add_field(name="⚡ Priorité", value=cls.format_priority(ticket.priority), inline=True)
+
+ if ticket.reason:
+ embed.add_field(name="📝 Raison", value=ticket.reason, inline=False)
+
+ embed.set_footer(text=f"ID: {ticket.channel_id} | {guild_name}")
+
+ return embed
+
+ @classmethod
+ def ticket_closed(
+ cls,
+ ticket: TicketData,
+ closed_by: User,
+ duration_minutes: float,
+ reason: Optional[str] = None
+ ) -> Embed:
+ """Embed for when a ticket is closed"""
+ embed = Embed(
+ title="🔴 Ticket Fermé",
+ description=f"Ce ticket a été fermé par {closed_by.mention}",
+ color=Color.red(),
+ timestamp=datetime.now()
+ )
+
+ embed.add_field(name="👤 Fermé par", value=closed_by.mention, inline=True)
+ embed.add_field(name="⏱️ Durée", value=cls._format_duration(duration_minutes), inline=True)
+ embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
+
+ if reason:
+ embed.add_field(name="📝 Raison de fermeture", value=reason, inline=False)
+
+ return embed
+
+ @classmethod
+ def ticket_reopened(cls, ticket: TicketData, reopened_by: User) -> Embed:
+ """Embed for when a ticket is reopened"""
+ embed = Embed(
+ title="🟢 Ticket Rouvert",
+ description=f"Ce ticket a été rouvert par {reopened_by.mention}",
+ color=Color.green(),
+ timestamp=datetime.now()
+ )
+
+ embed.add_field(name="👤 Rouvert par", value=reopened_by.mention, inline=True)
+ embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
+
+ return embed
+
+ @classmethod
+ def ticket_claimed(cls, ticket: TicketData, claimed_by: User) -> Embed:
+ """Embed for when a ticket is claimed"""
+ embed = Embed(
+ title="🟡 Ticket Réclamé",
+ description=f"Ce ticket a été réclamé par {claimed_by.mention}",
+ color=Color.gold(),
+ timestamp=datetime.now()
+ )
+
+ embed.add_field(name="👤 Réclamé par", value=claimed_by.mention, inline=True)
+ embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
+
+ return embed
+
+ @classmethod
+ def ticket_unclaimed(cls, ticket: TicketData, unclaimed_by: User) -> Embed:
+ """Embed for when a ticket claim is removed"""
+ embed = Embed(
+ title="🟠 Ticket Non Réclamé",
+ description=f"Le ticket n'est plus réclamé par {unclaimed_by.mention}",
+ color=Color.orange(),
+ timestamp=datetime.now()
+ )
+
+ return embed
+
+ # --- Panel Embeds ---
+
+ @classmethod
+ def panel_embed(
+ cls,
+ config: GuildTicketConfig,
+ guild_name: str
+ ) -> Embed:
+ """Embed for the ticket creation panel"""
+ embed = Embed(
+ title="🎫 Support - Créer un Ticket",
+ description="Cliquez sur le bouton ci-dessous pour créer un ticket. Notre équipe vous répondra dans les plus brefs délais.",
+ color=Color.blue(),
+ timestamp=datetime.now()
+ )
+
+ # Add categories
+ if config.categories:
+ categories_text = "\n".join(
+ f"{cat.emoji} **{cat.name}**\n{cat.description}"
+ for cat in config.categories
+ )
+ embed.add_field(name="📁 Catégories disponibles", value=categories_text, inline=False)
+
+ embed.set_footer(text=f"ID: {config.guild_id} | {guild_name}")
+
+ return embed
+
+ @classmethod
+ def category_select_embed(cls, categories: List[TicketCategory]) -> Embed:
+ """Embed shown when selecting a category"""
+ embed = Embed(
+ title="📁 Choisir une catégorie",
+ description="Sélectionnez la catégorie qui correspond le mieux à votre demande:",
+ color=Color.blue(),
+ timestamp=datetime.now()
+ )
+
+ for cat in categories:
+ embed.add_field(
+ name=f"{cat.emoji} {cat.name}",
+ value=cat.description or "Aucune description",
+ inline=False
+ )
+
+ return embed
+
+ # --- Admin Embeds ---
+
+ @classmethod
+ def ticket_list_embed(
+ cls,
+ tickets: List[TicketData],
+ user: Optional[User] = None
+ ) -> Embed:
+ """Embed showing a list of tickets"""
+ embed = Embed(
+ title="📋 Liste des Tickets",
+ description=f"Nombre de tickets: **{len(tickets)}**",
+ color=Color.blue()
+ )
+
+ if user:
+ embed.set_author(name=f"Tickets de {user.display_name}", icon_url=user.display_avatar.url)
+
+ # Group by status
+ by_status = {}
+ for ticket in tickets:
+ status = cls.format_status(ticket.status)
+ by_status.setdefault(status, []).append(ticket)
+
+ for status, status_tickets in by_status.items():
+ ticket_list = "\n".join(
+ f"`{t.channel_id}` - {t.category_id} ({cls.format_priority(t.priority)})"
+ for t in status_tickets[:10] # Limit to 10 per status
+ )
+ if len(status_tickets) > 10:
+ ticket_list += f"\n... et {len(status_tickets) - 10} autres"
+
+ embed.add_field(name=status, value=ticket_list or "Aucun", inline=False)
+
+ return embed
+
+ @classmethod
+ def stats_embed(cls, stats: TicketStats, guild_name: str) -> Embed:
+ """Embed showing ticket statistics"""
+ embed = Embed(
+ title="📊 Statistiques des Tickets",
+ color=Color.blue(),
+ timestamp=datetime.now()
+ )
+
+ embed.set_author(name=guild_name)
+
+ # Main stats
+ embed.add_field(name="📝 Total", value=str(stats.total_tickets), inline=True)
+ embed.add_field(name="🟢 Ouverts", value=str(stats.open_tickets), inline=True)
+ embed.add_field(name="🔴 Fermés", value=str(stats.closed_tickets), inline=True)
+
+ embed.add_field(name="⏱️ Durée Moyenne", value=cls._format_duration(stats.average_duration_minutes), inline=True)
+ embed.add_field(name="⭐ Note Moyenne", value=f"{stats.average_rating:.1f}/5" if stats.average_rating else "N/A", inline=True)
+
+ # By category
+ if stats.tickets_by_category:
+ cat_text = "\n".join(f"{cat}: **{count}**" for cat, count in stats.tickets_by_category.items())
+ embed.add_field(name="📁 Par Catégorie", value=cat_text, inline=False)
+
+ # By priority
+ if stats.tickets_by_priority:
+ priority_text = "\n".join(f"{prio}: **{count}**" for prio, count in stats.tickets_by_priority.items())
+ embed.add_field(name="⚡ Par Priorité", value=priority_text, inline=False)
+
+ return embed
+
+ @classmethod
+ def ticket_info_embed(
+ cls,
+ ticket: TicketData,
+ user: Optional[User] = None,
+ category: Optional[TicketCategory] = None
+ ) -> Embed:
+ """Embed showing detailed ticket information"""
+ embed = Embed(
+ title=f"🎫 Informations du Ticket",
+ color=cls.get_priority_color(ticket.priority),
+ timestamp=datetime.now()
+ )
+
+ if user:
+ embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
+
+ embed.add_field(name="📌 ID", value=f"`{ticket.channel_id}`", inline=True)
+ embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
+ embed.add_field(name="⚡ Priorité", value=cls.format_priority(ticket.priority), inline=True)
+ embed.add_field(name="📁 Catégorie", value=category.name if category else ticket.category_id, inline=True)
+
+ embed.add_field(name="📅 Créé le", value=ticket.created_at.strftime("%d/%m/%Y à %H:%M"), inline=True)
+
+ if ticket.closed_at:
+ embed.add_field(name="🔴 Fermé le", value=ticket.closed_at.strftime("%d/%m/%Y à %H:%M"), inline=True)
+
+ if ticket.claimed_by:
+ embed.add_field(name="👤 Réclamé par", value=f"<@{ticket.claimed_by}>", inline=True)
+
+ if ticket.reason:
+ embed.add_field(name="📝 Raison", value=ticket.reason, inline=False)
+
+ if ticket.duration_minutes:
+ embed.add_field(name="⏱️ Durée", value=cls._format_duration(ticket.duration_minutes), inline=True)
+
+ if ticket.rating:
+ stars = "⭐" * ticket.rating
+ embed.add_field(name="⭐ Note", value=f"{stars} ({ticket.rating}/5)", inline=True)
+
+ return embed
+
+ # --- Survey Embeds ---
+
+ @classmethod
+ def survey_embed(cls) -> Embed:
+ """Embed for satisfaction survey"""
+ embed = Embed(
+ title="⭐ Évaluation du Support",
+ description="Merci d'avoir utilisé notre système de tickets! "
+ "Pour améliorer nos services, merci de noter votre expérience.",
+ color=Color.gold(),
+ timestamp=datetime.now()
+ )
+
+ embed.add_field(name="1 ⭐", value="Très insatisfait", inline=True)
+ embed.add_field(name="3 ⭐", value="Neutre", inline=True)
+ embed.add_field(name="5 ⭐", value="Très satisfait", inline=True)
+
+ return embed
+
+ @classmethod
+ def survey_thanks_embed(cls, rating: int) -> Embed:
+ """Embed shown after submitting a survey"""
+ stars = "⭐" * rating
+ embed = Embed(
+ title="Merci pour votre évaluation!",
+ description=f"Vous avez attribué: {stars}",
+ color=Color.green()
+ )
+ return embed
+
+ # --- Helper Methods ---
+
+ @staticmethod
+ def _format_duration(minutes: float) -> str:
+ """Format duration in minutes to human-readable string"""
+ if minutes < 1:
+ return f"{minutes * 60:.0f}s"
+ elif minutes < 60:
+ return f"{minutes:.0f}min"
+ elif minutes < 1440:
+ hours = minutes / 60
+ return f"{hours:.1f}h"
+ else:
+ days = minutes / 1440
+ return f"{days:.1f}j"
+
+
+class TicketMessageFormatter:
+ """Formats regular messages for the ticket system"""
+
+ @staticmethod
+ def welcome_message(
+ user: User,
+ category: TicketCategory
+ ) -> str:
+ """Welcome message when entering a ticket"""
+ return (
+ f"👋 Bienvenue {user.mention} dans votre ticket **{category.name}**!\n\n"
+ f"{category.welcome_message}\n\n"
+ f"Un membre de notre équipe va vous répondre sous peu."
+ )
+
+ @staticmethod
+ def staff_notification(
+ claimed_by: User,
+ ticket_channel_mention: str
+ ) -> str:
+ """Notification when a ticket is claimed"""
+ return (
+ f"🟡 Le ticket {ticket_channel_mention} a été réclamé par {claimed_by.mention}"
+ )
+
+ @staticmethod
+ def ticket_closed_notification(
+ closed_by: User,
+ reason: Optional[str] = None
+ ) -> str:
+ """Notification when a ticket is closed"""
+ msg = f"🔴 Ce ticket a été fermé par {closed_by.mention}"
+ if reason:
+ msg += f"\n📝 Raison: {reason}"
+ return msg
+
+ @staticmethod
+ def auto_close_warning(
+ days_remaining: int,
+ ticket_channel_mention: str
+ ) -> str:
+ """Warning before auto-closing a ticket"""
+ return (
+ f"⚠️ Ce ticket {ticket_channel_mention} sera fermé automatiquement "
+ f"dans **{days_remaining} jour(s)** en raison de son inactivity."
+ )
+
+ @staticmethod
+ def transcript_saved(filepath: str) -> str:
+ """Message when transcript is saved"""
+ return f"📄 Transcript sauvegardé: `{filepath}`"
+
+ @staticmethod
+ def no_permission(action: str) -> str:
+ """Message when user has no permission"""
+ return f"❌ Vous n'avez pas la permission de {action}"
+
+ @staticmethod
+ def ticket_limit_reached(limit: int) -> str:
+ """Message when user has reached ticket limit"""
+ return f"❌ Vous avez atteint la limite de {limit} tickets ouverts"
+
+ @staticmethod
+ def already_has_ticket(channel_mention: str) -> str:
+ """Message when user already has a ticket"""
+ return f"❌ Vous avez déjà un ticket ouvert: {channel_mention}"
+
diff --git a/commandes/ticket/utils/transcript.py b/commandes/ticket/utils/transcript.py
new file mode 100644
index 0000000..7c88e40
--- /dev/null
+++ b/commandes/ticket/utils/transcript.py
@@ -0,0 +1,496 @@
+"""
+Ticket Transcript Generator
+===========================
+Generates beautiful transcript files from ticket conversations.
+"""
+import json
+import os
+from datetime import datetime
+from typing import Optional, List, Dict, Any
+from discord import User, Member
+
+from ..data.models import TicketData, TicketMessage
+
+
+class TranscriptGenerator:
+ """
+ Generates formatted transcripts from ticket data.
+
+ Features:
+ - Multiple output formats (TXT, JSON, HTML)
+ - Customizable templates
+ - Attachment handling
+ - Statistics inclusion
+ """
+
+ TRANSCRIPT_DIR = "data/tickets/transcripts"
+
+ def __init__(self):
+ os.makedirs(self.TRANSCRIPT_DIR, exist_ok=True)
+
+ def generate_txt(
+ self,
+ ticket: TicketData,
+ messages: Optional[List[Dict[str, Any]]] = None,
+ include_stats: bool = True
+ ) -> str:
+ """Generate a plain text transcript"""
+ lines = []
+
+ # Header
+ lines.append("=" * 60)
+ lines.append("TRANSCRIPT DE TICKET")
+ lines.append("=" * 60)
+ lines.append("")
+ lines.append(f"ID du salon: {ticket.channel_id}")
+ lines.append(f"ID du serveur: {ticket.guild_id}")
+ lines.append(f"Créé par: {ticket.user_id}")
+ lines.append(f"Catégorie: {ticket.category_id}")
+ lines.append(f"Statut: {ticket.status.value}")
+ lines.append(f"Priorité: {ticket.priority.value}")
+ lines.append(f"Créé le: {ticket.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
+ if ticket.closed_at:
+ lines.append(f"Fermé le: {ticket.closed_at.strftime('%Y-%m-%d %H:%M:%S')}")
+ if ticket.claimed_by:
+ lines.append(f"Réclamé par: {ticket.claimed_by}")
+ if ticket.reason:
+ lines.append(f"Raison: {ticket.reason}")
+ lines.append("")
+ lines.append("-" * 60)
+ lines.append("HISTORIQUE DES MESSAGES")
+ lines.append("-" * 60)
+ lines.append("")
+
+ # Messages
+ if messages:
+ for msg in messages:
+ timestamp = msg.get("timestamp", "")
+ if isinstance(timestamp, str):
+ try:
+ dt = datetime.fromisoformat(timestamp)
+ timestamp = dt.strftime("%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ pass
+
+ author = msg.get("author_name", "Inconnu")
+ content = msg.get("content", "")
+ is_staff = msg.get("is_staff", False)
+ staff_marker = " [STAFF]" if is_staff else ""
+
+ lines.append(f"[{timestamp}] {author}{staff_marker}")
+ if content:
+ lines.append(f" {content}")
+ lines.append("")
+
+ # Statistics
+ if include_stats and ticket.duration_minutes:
+ lines.append("-" * 60)
+ lines.append("STATISTIQUES")
+ lines.append("-" * 60)
+ lines.append(f"Durée: {ticket.duration_minutes:.1f} minutes")
+ lines.append(f"Nombre de messages: {len(messages) if messages else len(ticket.messages)}")
+
+ if ticket.rating:
+ stars = "⭐" * ticket.rating
+ lines.append(f"Note: {stars} ({ticket.rating}/5)")
+ if ticket.feedback:
+ lines.append(f"Feedback: {ticket.feedback}")
+
+ lines.append("")
+ lines.append("=" * 60)
+ lines.append(f"Généré le: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ lines.append("=" * 60)
+
+ return "\n".join(lines)
+
+ def generate_json(
+ self,
+ ticket: TicketData,
+ messages: Optional[List[Dict[str, Any]]] = None,
+ include_stats: bool = True
+ ) -> Dict[str, Any]:
+ """Generate a JSON transcript"""
+ data = {
+ "version": "1.0",
+ "generated_at": datetime.now().isoformat(),
+ "ticket": {
+ "channel_id": ticket.channel_id,
+ "guild_id": ticket.guild_id,
+ "user_id": ticket.user_id,
+ "category_id": ticket.category_id,
+ "status": ticket.status.value,
+ "priority": ticket.priority.value,
+ "created_at": ticket.created_at.isoformat(),
+ "closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
+ "claimed_by": ticket.claimed_by,
+ "reason": ticket.reason,
+ "rating": ticket.rating,
+ "feedback": ticket.feedback
+ },
+ "messages": messages or [m.to_dict() for m in ticket.messages]
+ }
+
+ if include_stats:
+ data["statistics"] = {
+ "duration_minutes": ticket.duration_minutes,
+ "message_count": len(messages) if messages else len(ticket.messages),
+ "duration_formatted": self._format_duration(ticket.duration_minutes) if ticket.duration_minutes else None
+ }
+
+ return data
+
+ def generate_html(
+ self,
+ ticket: TicketData,
+ messages: Optional[List[Dict[str, Any]]] = None,
+ include_stats: bool = True,
+ guild_name: str = "Serveur Discord"
+ ) -> str:
+ """Generate an HTML transcript with styling"""
+
+ # Get priority color
+ priority_colors = {
+ "low": "#2ecc71",
+ "normal": "#3498db",
+ "high": "#e67e22",
+ "urgent": "#e74c3c"
+ }
+ priority_color = priority_colors.get(ticket.priority.value, "#3498db")
+
+ html_messages = ""
+ if messages:
+ for msg in messages:
+ timestamp = msg.get("timestamp", "")
+ if isinstance(timestamp, str):
+ try:
+ dt = datetime.fromisoformat(timestamp)
+ timestamp = dt.strftime("%d/%m/%Y %H:%M:%S")
+ except ValueError:
+ pass
+
+ author = msg.get("author_name", "Inconnu")
+ content = msg.get("content", "").replace("\n", "
")
+ is_staff = msg.get("is_staff", False)
+
+ staff_class = " staff-message" if is_staff else ""
+ staff_badge = 'STAFF' if is_staff else ""
+
+ html_messages += f"""
+