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""" +
+
+ {author} + {staff_badge} + {timestamp} +
+
{content}
+
+ """ + + # Statistics section + stats_html = "" + if include_stats: + duration = self._format_duration(ticket.duration_minutes) if ticket.duration_minutes else "N/A" + rating_stars = "⭐" * ticket.rating if ticket.rating else "Pas de note" + + stats_html = f""" +
+

📊 Statistiques

+
+
+ Durée + {duration} +
+
+ Messages + {len(messages) if messages else len(ticket.messages)} +
+
+ Note + {rating_stars} +
+
+
+ """ + + html = f""" + + + + + Transcript - Ticket #{ticket.channel_id} + + + +
+
+

🎫 Transcript de Ticket

+
+
+ 📌 ID: + {ticket.channel_id} +
+
+ 📁 Catégorie: + {ticket.category_id} +
+
+ ⚡ Priorité: + {ticket.priority.value.upper()} +
+
+ 📊 Statut: + {ticket.status.value} +
+
+ 📅 Créé: + {ticket.created_at.strftime('%d/%m/%Y %H:%M')} +
+
+
+ +
+

💬 Messages ({len(messages) if messages else len(ticket.messages)})

+ {html_messages} +
+ + {stats_html} + + +
+ +""" + + return html + + def save_transcript( + self, + ticket: TicketData, + messages: Optional[List[Dict[str, Any]]] = None, + format: str = "txt", + include_stats: bool = True + ) -> Optional[str]: + """Save a transcript to file and return the path""" + + if format == "txt": + content = self.generate_txt(ticket, messages, include_stats) + ext = "txt" + elif format == "json": + content = json.dumps(self.generate_json(ticket, messages, include_stats), indent=2, ensure_ascii=False) + ext = "json" + elif format == "html": + content = self.generate_html(ticket, messages, include_stats) + ext = "html" + else: + return None + + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"ticket_{ticket.channel_id}_{timestamp}.{ext}" + filepath = os.path.join(self.TRANSCRIPT_DIR, filename) + + try: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return filepath + except Exception as e: + print(f"[TranscriptGenerator] Error saving transcript: {e}") + return None + + def _format_duration(self, minutes: float) -> str: + """Format duration in minutes to human-readable string""" + if 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" + + def get_transcript_path(self, channel_id: int) -> Optional[str]: + """Get the most recent transcript for a channel""" + if not os.path.exists(self.TRANSCRIPT_DIR): + return None + + files = [f for f in os.listdir(self.TRANSCRIPT_DIR) + if f.startswith(f"ticket_{channel_id}_")] + + if not files: + return None + + latest = max(files, key=lambda x: os.path.getmtime(os.path.join(self.TRANSCRIPT_DIR, x))) + return os.path.join(self.TRANSCRIPT_DIR, latest) + + def cleanup_old_transcripts(self, days: int = 30) -> int: + """Remove transcripts older than X days""" + if not os.path.exists(self.TRANSCRIPT_DIR): + return 0 + + cutoff = datetime.now().timestamp() - (days * 86400) + removed = 0 + + for filename in os.listdir(self.TRANSCRIPT_DIR): + filepath = os.path.join(self.TRANSCRIPT_DIR, filename) + if os.path.getmtime(filepath) < cutoff: + os.remove(filepath) + removed += 1 + + return removed + + +# Singleton instance +_transcript_instance: Optional[TranscriptGenerator] = None + + +def get_transcript_generator() -> TranscriptGenerator: + """Get the singleton transcript generator instance""" + global _transcript_instance + if _transcript_instance is None: + _transcript_instance = TranscriptGenerator() + return _transcript_instance + diff --git a/commandes/ticket/views/__init__.py b/commandes/ticket/views/__init__.py new file mode 100644 index 0000000..ada503d --- /dev/null +++ b/commandes/ticket/views/__init__.py @@ -0,0 +1,35 @@ +""" +Ticket Views Module +=================== +UI views for the ticket system. +""" +from .creation import ( + TicketCreationView, + TicketPriorityModal, + TicketManagementView, + TicketCloseModal, + TicketPriorityChangeModal, + SurveyView, +) +from .admin import ( + AdminPanelView, + BulkCloseView, + ConfigPanelView, + AddCategoryModal, + RolesManagementView, +) + +__all__ = [ + "TicketCreationView", + "TicketPriorityModal", + "TicketManagementView", + "TicketCloseModal", + "TicketPriorityChangeModal", + "SurveyView", + "AdminPanelView", + "BulkCloseView", + "ConfigPanelView", + "AddCategoryModal", + "RolesManagementView", +] + diff --git a/commandes/ticket/views/admin.py b/commandes/ticket/views/admin.py new file mode 100644 index 0000000..ada7b99 --- /dev/null +++ b/commandes/ticket/views/admin.py @@ -0,0 +1,522 @@ +""" +Ticket Admin Views +================== +Admin panel views for bulk management and configuration. +""" +from datetime import datetime +from typing import Optional, List, Dict, Any + +import discord +from discord.ui import View, Button, Select, Modal, TextInput +from discord import Interaction, ButtonStyle, Color + +from ..data.models import ( + TicketData, + TicketCategory, + TicketStatus, + TicketStats, + GuildTicketConfig +) +from ..data.storage import get_storage +from ..utils.formatters import TicketEmbedFormatter +from ..utils.transcript import get_transcript_generator + + +class AdminPanelView(View): + """ + Main admin panel for ticket management. + """ + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.config = config + + # Stats button + stats_btn = Button( + style=ButtonStyle.blurple, + label="📊 Statistiques", + custom_id="admin_stats" + ) + stats_btn.callback = self._stats_callback + self.add_item(stats_btn) + + # Bulk close button + bulk_close_btn = Button( + style=ButtonStyle.red, + label="🔴 Fermeture Massive", + custom_id="admin_bulk_close" + ) + bulk_close_btn.callback = self._bulk_close_callback + self.add_item(bulk_close_btn) + + # Config button + config_btn = Button( + style=ButtonStyle.secondary, + label="⚙️ Configuration", + custom_id="admin_config" + ) + config_btn.callback = self._config_callback + self.add_item(config_btn) + + # Clean old tickets button + cleanup_btn = Button( + style=ButtonStyle.secondary, + label="🧹 Nettoyer", + custom_id="admin_cleanup" + ) + cleanup_btn.callback = self._cleanup_callback + self.add_item(cleanup_btn) + + async def _stats_callback(self, interaction: Interaction): + """Show statistics""" + await interaction.response.defer(ephemeral=True) + + storage = get_storage() + stats = self._calculate_stats(interaction.guild_id) + + embed = TicketEmbedFormatter.stats_embed(stats, interaction.guild.name) + + await interaction.followup.send(embed=embed, ephemeral=True) + + async def _bulk_close_callback(self, interaction: Interaction): + """Show bulk close options""" + await interaction.response.defer(ephemeral=True) + + # Get open tickets + storage = get_storage() + tickets = storage.get_guild_tickets(interaction.guild_id, TicketStatus.OPEN) + + if not tickets: + await interaction.followup.send("Aucun ticket ouvert à fermer.", ephemeral=True) + return + + # Show selection view + view = BulkCloseView(self.bot, self.config, tickets) + embed = discord.Embed( + title="🔴 Fermeture Massive", + description=f"Nombre de tickets ouverts: **{len(tickets)}**\n\n" + "Sélectionnez les tickets à fermer:", + color=Color.red() + ) + + await interaction.followup.send(embed=embed, view=view, ephemeral=True) + + async def _config_callback(self, interaction: Interaction): + """Show configuration options""" + await interaction.response.defer(ephemeral=True) + + view = ConfigPanelView(self.bot, self.config) + + embed = discord.Embed( + title="⚙️ Configuration des Tickets", + color=Color.blue() + ) + embed.add_field(name="Tickets activés", value="✅" if self.config.enabled else "❌", inline=True) + embed.add_field(name="Catégories", value=str(len(self.config.categories)), inline=True) + embed.add_field(name="Rôles Staff", value=str(len(self.config.staff_roles)), inline=True) + embed.add_field(name="Canal Logs", value=f"<#{self.config.log_channel_id}>" if self.config.log_channel_id else "Non configuré", inline=True) + embed.add_field(name="Limite par utilisateur", value=str(self.config.max_tickets_per_user), inline=True) + embed.add_field(name="Auto-close", value="✅" if self.config.auto_close_enabled else "❌", inline=True) + + await interaction.followup.send(embed=embed, view=view, ephemeral=True) + + async def _cleanup_callback(self, interaction: Interaction): + """Clean up old closed tickets""" + await interaction.response.defer(ephemeral=True) + + storage = get_storage() + removed = await self._cleanup_closed_tickets(interaction.guild_id) + + await interaction.followup.send( + f"✅ {removed} tickets fermés ont été supprimés.", + ephemeral=True + ) + + def _calculate_stats(self, guild_id: int) -> TicketStats: + """Calculate ticket statistics""" + 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: + # By status + 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 + + # By category + stats.tickets_by_category[ticket.category_id] = \ + stats.tickets_by_category.get(ticket.category_id, 0) + 1 + + # By priority + stats.tickets_by_priority[ticket.priority.value] = \ + stats.tickets_by_priority.get(ticket.priority.value, 0) + 1 + + # By day + day = ticket.created_at.strftime("%Y-%m-%d") + stats.tickets_by_day[day] = stats.tickets_by_day.get(day, 0) + 1 + + # Duration + if ticket.duration_minutes: + total_duration += ticket.duration_minutes + + # Rating + if ticket.rating: + rating_sum += ticket.rating + rating_count += 1 + + # Averages + 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 + + async def _cleanup_closed_tickets(self, guild_id: int, days: int = 30) -> int: + """Remove closed tickets older than X days""" + storage = get_storage() + tickets = storage.get_guild_tickets(guild_id, TicketStatus.CLOSED) + + cutoff = datetime.now().timestamp() - (days * 86400) + removed = 0 + + for ticket in tickets: + if ticket.closed_at and ticket.closed_at.timestamp() < cutoff: + # Delete channel if it exists + channel = self.bot.get_channel(ticket.channel_id) + if channel: + try: + await channel.delete() + except Exception: + pass + + # Delete ticket data + storage.delete_ticket(ticket.channel_id) + removed += 1 + + return removed + + +class BulkCloseView(View): + """View for selecting tickets to bulk close""" + + def __init__(self, bot, config: GuildTicketConfig, tickets: List[TicketData]): + super().__init__(timeout=None) # Persistent view + self.bot = bot + self.config = config + self.tickets = tickets + self.selected_tickets = set() + + # Create select with tickets (max 25 per select) + options = [] + for ticket in tickets[:25]: + channel = self.bot.get_channel(ticket.channel_id) + channel_name = channel.name if channel else f"ticket_{ticket.channel_id}" + options.append( + discord.SelectOption( + label=channel_name[:100], + value=str(ticket.channel_id), + description=f"Priorité: {ticket.priority.value}" + ) + ) + + if options: + select = Select( + placeholder="Sélectionnez les tickets à fermer...", + options=options, + min_values=1, + max_values=len(options) + ) + select.callback = self._select_callback + self.add_item(select) + + # Confirm button + confirm_btn = Button( + style=ButtonStyle.red, + label="Confirmer la fermeture", + custom_id="bulk_confirm" + ) + confirm_btn.callback = self._confirm_callback + self.add_item(confirm_btn) + + async def _select_callback(self, interaction: Interaction): + """Handle ticket selection""" + self.selected_tickets = set(int(v) for v in interaction.data["values"]) + await interaction.response.send_message( + f"*{len(self.selected_tickets)} ticket(s) sélectionné(s)*", + ephemeral=True + ) + + async def _confirm_callback(self, interaction: Interaction): + """Confirm bulk close""" + await interaction.response.defer(ephemeral=True) + + if not self.selected_tickets: + await interaction.followup.send("Aucun ticket sélectionné.", ephemeral=True) + return + + storage = get_storage() + closed_count = 0 + + for channel_id in self.selected_tickets: + ticket = storage.load_ticket(channel_id) + if ticket: + ticket.status = TicketStatus.CLOSED + ticket.closed_at = datetime.now() + storage.save_ticket(ticket) + + # Update channel + channel = interaction.guild.get_channel(channel_id) + if channel: + # Update permissions + overwrites = channel.overwrites.copy() + for target, perms in overwrites.items(): + if isinstance(target, discord.Member) and target.id != interaction.guild.me.id: + overwrites[target] = discord.PermissionOverwrite( + read_messages=True, + send_messages=False + ) + + await channel.edit( + name=f"closed-{channel.name}", + overwrites=overwrites + ) + + await channel.send(f"Fermé par bulk close (Admin: {interaction.user.mention})") + + closed_count += 1 + + await interaction.followup.send( + f"✅ {closed_count} ticket(s) fermé(s).", + ephemeral=True + ) + + +class ConfigPanelView(View): + """Configuration panel view""" + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.config = config + + # Toggle enabled + toggle_btn = Button( + style=ButtonStyle.green if config.enabled else ButtonStyle.red, + label="Activer/Désactiver", + custom_id="config_toggle" + ) + toggle_btn.callback = self._toggle_callback + self.add_item(toggle_btn) + + # Add category + add_cat_btn = Button( + style=ButtonStyle.blurple, + label="➕ Ajouter Catégorie", + custom_id="config_add_category" + ) + add_cat_btn.callback = self._add_category_callback + self.add_item(add_cat_btn) + + # Edit roles + roles_btn = Button( + style=ButtonStyle.secondary, + label="👥 Gérer Rôles Staff", + custom_id="config_roles" + ) + roles_btn.callback = self._roles_callback + self.add_item(roles_btn) + + # Toggle auto-close + auto_close_btn = Button( + style=ButtonStyle.green if config.auto_close_enabled else ButtonStyle.red, + label="Auto-close", + custom_id="config_auto_close" + ) + auto_close_btn.callback = self._auto_close_callback + self.add_item(auto_close_btn) + + async def _toggle_callback(self, interaction: Interaction): + """Toggle ticket system""" + self.config.enabled = not self.config.enabled + + storage = get_storage() + storage.save_config(interaction.guild_id, self.config) + + status = "activé" if self.config.enabled else "désactivé" + await interaction.response.send_message( + f"Le système de tickets est maintenant **{status}**.", + ephemeral=True + ) + + async def _add_category_callback(self, interaction: Interaction): + """Show add category modal""" + modal = AddCategoryModal(self.bot, self.config) + await interaction.response.send_modal(modal) + + async def _roles_callback(self, interaction: Interaction): + """Show role management""" + view = RolesManagementView(self.bot, self.config) + + embed = discord.Embed( + title="👥 Gestion des Rôles Staff", + description="Sélectionnez les rôles qui auront accès aux tickets:", + color=Color.blue() + ) + + # Show current roles + 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.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) + + async def _auto_close_callback(self, interaction: Interaction): + """Toggle auto-close""" + self.config.auto_close_enabled = not self.config.auto_close_enabled + + storage = get_storage() + storage.save_config(interaction.guild_id, self.config) + + status = "activé" if self.config.auto_close_enabled else "désactivé" + await interaction.response.send_message( + f"L'auto-close est maintenant **{status}**.", + ephemeral=True + ) + + +class AddCategoryModal(Modal): + """Modal for adding a new category""" + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(title="➕ Nouvelle Catégorie") + self.bot = bot + self.config = config + + self.name = TextInput( + label="Nom", + placeholder="Ex: Support", + required=True + ) + self.add_item(self.name) + + self.description = TextInput( + label="Description", + placeholder="Description de la catégorie...", + required=False, + style=discord.TextStyle.paragraph + ) + self.add_item(self.description) + + self.emoji = TextInput( + label="Emoji", + placeholder="🎫", + required=False + ) + self.add_item(self.emoji) + + self.welcome_msg = TextInput( + label="Message de bienvenue", + placeholder="Message affiché quand un ticket est créé...", + required=False, + style=discord.TextStyle.paragraph + ) + self.add_item(self.welcome_msg) + + async def on_submit(self, interaction: Interaction): + # Generate ID + 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 "🎫", + welcome_message=self.welcome_msg.value or "Merci d'avoir créé un ticket." + ) + + self.config.categories.append(category) + + if not self.config.default_category: + self.config.default_category = cat_id + + storage = get_storage() + 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 RolesManagementView(View): + """View for managing staff roles""" + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.config = config + + # Create select with guild roles + guild = bot.guilds[0] if bot.guilds else None + options = [] + + if guild: + for role in guild.roles: + if role.id != guild.id: # Skip @everyone + selected = str(role.id) in config.staff_roles + options.append( + discord.SelectOption( + label=role.name, + value=str(role.id), + default=selected + ) + ) + + if options: + select = Select( + placeholder="Sélectionnez les rôles staff...", + options=options[:25] + ) + select.callback = self._select_callback + self.add_item(select) + + async def _select_callback(self, interaction: Interaction): + """Handle role selection""" + selected_ids = set(interaction.data["values"]) + + # Update config + self.config.staff_roles = list(selected_ids) + + storage = get_storage() + storage.save_config(interaction.guild_id, self.config) + + await interaction.response.send_message( + f"*{len(selected_ids)} rôle(s) mis à jour.*", + ephemeral=True + ) + diff --git a/commandes/ticket/views/creation.py b/commandes/ticket/views/creation.py new file mode 100644 index 0000000..155ad8e --- /dev/null +++ b/commandes/ticket/views/creation.py @@ -0,0 +1,692 @@ +""" +Ticket Creation Views +===================== +UI views for creating tickets with categories, priority, and reason. +""" +from datetime import datetime +from typing import Optional, List, Dict, Any + +import discord +from discord.ui import View, Button, Select, Modal, TextInput +from discord import Interaction, ButtonStyle, Color + +from ..data.models import ( + TicketData, + TicketCategory, + TicketStatus, + Priority, + GuildTicketConfig +) +from ..data.storage import get_storage +from ..utils.formatters import TicketEmbedFormatter, TicketMessageFormatter +from ..utils.transcript import get_transcript_generator + + +class TicketCreationView(View): + """ + Main view for ticket creation panel. + Shows category selection buttons. + """ + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.config = config + + # Add category buttons + for i, category in enumerate(config.categories[:5]): # Max 5 categories + btn = Button( + style=ButtonStyle.blurple, + label=category.name, + emoji=category.emoji, + custom_id=f"ticket_category_{category.id}" + ) + btn.callback = self._create_category_callback(category.id) + self.add_item(btn) + + # Add help button + help_btn = Button( + style=ButtonStyle.secondary, + label="Aide", + emoji="❓", + custom_id="ticket_help" + ) + help_btn.callback = self._help_callback + self.add_item(help_btn) + + def _create_category_callback(self, category_id: str): + """Create callback for category button""" + async def callback(interaction: Interaction): + await self.on_category_select(interaction, category_id) + return callback + + async def on_category_select(self, interaction: Interaction, category_id: str): + """Handle category selection""" + await interaction.response.defer(ephemeral=True) + + category = self.config.get_category(category_id) + if not category: + await interaction.followup.send("Catégorie non trouvée.", ephemeral=True) + return + + storage = get_storage() + + # Check if user already has a ticket + user_tickets = storage.get_user_tickets( + interaction.guild_id, + interaction.user.id, + open_only=True + ) + + # Check limits + user_count = len([t for t in user_tickets if t.category_id == category_id]) + if user_count >= category.max_tickets_per_user: + await interaction.followup.send( + TicketMessageFormatter.ticket_limit_reached(category.max_tickets_per_user), + ephemeral=True + ) + return + + # Check global limit + if len(user_tickets) >= self.config.max_tickets_per_user: + await interaction.followup.send( + TicketMessageFormatter.ticket_limit_reached(self.config.max_tickets_per_user), + ephemeral=True + ) + return + + # Show priority selection modal + modal = TicketPriorityModal( + bot=self.bot, + config=self.config, + category=category + ) + await interaction.response.send_modal(modal) + + async def _help_callback(self, interaction: Interaction): + """Handle help button click""" + embed = discord.Embed( + title="❓ Aide - Création de Ticket", + description="Sélectionnez une catégorie ci-dessus correspondant à votre demande:\n\n" + "• **Support** - Questions techniques\n" + "• **Bug Report** - Signaler un problème\n" + "• **Partenariat** - Propositions de partenariat\n" + "• **Autre** - Autres demandes", + color=Color.blue() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class TicketPriorityModal(Modal): + """ + Modal for selecting priority and reason when creating a ticket. + """ + + def __init__(self, bot, config: GuildTicketConfig, category: TicketCategory): + super().__init__(title=f"🎫 {category.name} - Nouveau Ticket") + self.bot = bot + self.config = config + self.category = category + + # Priority selection + priorities = ["Basse", "Normale", "Haute", "Urgente"] + priority_placeholder = "Sélectionnez la priorité" + + if not category.priority_enabled: + priorities = ["Normale"] + priority_placeholder = "Priorité par défaut" + + self.priority_select = Select( + placeholder=priority_placeholder, + options=[ + discord.SelectOption(label=p, value=p.lower(), emoji=emoji) + for p, emoji in [ + ("Basse", "🟢"), + ("Normale", "🔵"), + ("Haute", "🟠"), + ("Urgente", "🔴") + ] if p in priorities or p.lower() in ["normale"] + ], + custom_id="priority_select" + ) + self.add_item(self.priority_select) + + # Reason input + reason_placeholder = "Décrivez votre demande..." + if category.require_reason: + reason_placeholder = "Requis - Décrivez votre demande..." + + self.reason = TextInput( + label="Motif de la demande", + placeholder=reason_placeholder, + required=category.require_reason, + style=discord.TextStyle.paragraph, + custom_id="reason_input" + ) + self.add_item(self.reason) + + async def on_submit(self, interaction: Interaction): + """Handle modal submission""" + await interaction.response.defer(ephemeral=True) + + # Map priority + priority_map = { + "basse": Priority.LOW, + "normale": Priority.NORMAL, + "haute": Priority.HIGH, + "urgente": Priority.URGENT + } + priority = priority_map.get(self.priority_select.values[0], Priority.NORMAL) + + await self.create_ticket( + interaction=interaction, + priority=priority, + reason=self.reason.value + ) + + async def create_ticket( + self, + interaction: Interaction, + priority: Priority, + reason: str + ): + """Create the actual ticket""" + guild = interaction.guild + user = interaction.user + + # Get category for config + category = self.category + + # Get category channel + category_channel = guild.get_channel(int(category.id)) if category.id.isdigit() else None + + # Build permissions + overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + user: discord.PermissionOverwrite(read_messages=True, send_messages=True, attach_files=True), + guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True) + } + + # Add staff roles + for role_id in category.staff_roles: + role = guild.get_role(int(role_id)) + if role: + overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + + # Also add global staff roles + for role_id in self.config.staff_roles: + role = guild.get_role(int(role_id)) + if role and role not in overwrites: + overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + + # Create channel name + timestamp = datetime.now().strftime("%m%d%H%M") + channel_name = f"ticket-{user.id}-{timestamp}" + + # Create ticket channel + try: + ticket_channel = await guild.create_text_channel( + name=channel_name, + category=guild.get_channel(int(self.config.default_category)) if self.config.default_category else None, + overwrites=overwrites, + topic=f"Ticket de {user} ({user.id}) | Catégorie: {category.name}" + ) + except Exception as e: + await interaction.followup.send(f"❌ Erreur lors de la création du ticket: {e}", ephemeral=True) + return + + # Create ticket data + storage = get_storage() + ticket_data = TicketData( + channel_id=ticket_channel.id, + guild_id=guild.id, + user_id=user.id, + category_id=category.id, + priority=priority, + status=TicketStatus.OPEN, + reason=reason + ) + storage.save_ticket(ticket_data) + + # Send welcome message + welcome_content = TicketMessageFormatter.welcome_message(user, category) + welcome_embed = TicketEmbedFormatter.ticket_created(ticket_data, user, category, guild.name) + + # Send initial message with management view + view = TicketManagementView(self.bot, storage, self.config) + await ticket_channel.send( + content=welcome_content, + embed=welcome_embed, + view=view + ) + + # Send confirmation to user + await interaction.followup.send( + f"✅ Votre ticket a été créé: {ticket_channel.mention}", + ephemeral=True + ) + + # Log to log channel + if self.config.log_channel_id: + log_channel = guild.get_channel(self.config.log_channel_id) + if log_channel: + log_embed = discord.Embed( + title="🎫 Ticket Créé", + color=Color.green(), + timestamp=datetime.now() + ) + log_embed.add_field(name="👤 Utilisateur", value=user.mention, inline=True) + log_embed.add_field(name="📁 Catégorie", value=category.name, inline=True) + log_embed.add_field(name="⚡ Priorité", value=TicketEmbedFormatter.format_priority(priority), inline=True) + log_embed.add_field(name="📝 Salon", value=ticket_channel.mention, inline=True) + if reason: + log_embed.add_field(name="📄 Raison", value=reason[:100], inline=False) + + await log_channel.send(embed=log_embed) + + +class TicketManagementView(View): + """ + View shown inside tickets for management actions. + """ + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + + # Close button + close_btn = Button( + style=ButtonStyle.red, + label="Fermer", + emoji="🔒", + custom_id="ticket_close" + ) + close_btn.callback = self._close_callback + self.add_item(close_btn) + + # Claim button (if enabled) + if config.claim_enabled: + claim_btn = Button( + style=ButtonStyle.gold, + label="Réclamer", + emoji="🙋", + custom_id="ticket_claim" + ) + claim_btn.callback = self._claim_callback + self.add_item(claim_btn) + + # Priority button + priority_btn = Button( + style=ButtonStyle.secondary, + label="Priorité", + emoji="⚡", + custom_id="ticket_priority" + ) + priority_btn.callback = self._priority_callback + self.add_item(priority_btn) + + # Transcript button + transcript_btn = Button( + style=ButtonStyle.secondary, + label="Transcript", + emoji="📄", + custom_id="ticket_transcript" + ) + transcript_btn.callback = self._transcript_callback + self.add_item(transcript_btn) + + async def _close_callback(self, interaction: Interaction): + """Handle close button click""" + await interaction.response.defer(ephemeral=True) + + storage = get_storage() + ticket = storage.load_ticket(interaction.channel.id) + + if not ticket: + await interaction.followup.send("Ticket non trouvé.", ephemeral=True) + return + + # Check if user can close (owner or staff) + can_close = ( + interaction.user.id == ticket.user_id or + self._is_staff(interaction) + ) + + if not can_close: + await interaction.followup.send( + "Vous ne pouvez pas fermer ce ticket.", + ephemeral=True + ) + return + + # Show close modal + modal = TicketCloseModal(self.bot, storage, self.config, ticket) + await interaction.response.send_modal(modal) + + async def _claim_callback(self, interaction: Interaction): + """Handle claim button click""" + await interaction.response.defer(ephemeral=True) + + if not self._is_staff(interaction): + await interaction.followup.send( + "Vous n'êtes pas staff.", + ephemeral=True + ) + return + + storage = get_storage() + ticket = storage.load_ticket(interaction.channel.id) + + if not ticket: + await interaction.followup.send("Ticket non trouvé.", ephemeral=True) + return + + if ticket.status == TicketStatus.CLAIMED and ticket.claimed_by == interaction.user.id: + # Unclaim + ticket.claimed_by = None + ticket.status = TicketStatus.OPEN + storage.save_ticket(ticket) + + await interaction.followup.send("Ticket non réclamé.", ephemeral=True) + await interaction.channel.send( + embed=TicketEmbedFormatter.ticket_unclaimed(ticket, interaction.user) + ) + elif ticket.status == TicketStatus.CLAIMED: + await interaction.followup.send( + "Ce ticket est déjà réclamé par quelqu'un d'autre.", + ephemeral=True + ) + else: + # Claim + ticket.claimed_by = interaction.user.id + ticket.status = TicketStatus.CLAIMED + storage.save_ticket(ticket) + + await interaction.followup.send("Ticket réclamé!", ephemeral=True) + await interaction.channel.send( + embed=TicketEmbedFormatter.ticket_claimed(ticket, interaction.user) + ) + + async def _priority_callback(self, interaction: Interaction): + """Handle priority button click""" + await interaction.response.defer(ephemeral=True) + + if not self._is_staff(interaction): + await interaction.followup.send( + "Vous n'êtes pas staff.", + ephemeral=True + ) + return + + modal = TicketPriorityChangeModal(self.bot, get_storage(), self.config) + await interaction.response.send_modal(modal) + + async def _transcript_callback(self, interaction: Interaction): + """Handle transcript button click""" + await interaction.response.defer(ephemeral=True) + + if not self._is_staff(interaction): + await interaction.followup.send( + "Vous n'êtes pas staff.", + ephemeral=True + ) + return + + storage = get_storage() + ticket = storage.load_ticket(interaction.channel.id) + + if not ticket: + await interaction.followup.send("Ticket non trouvé.", ephemeral=True) + return + + # Generate transcript + transcript_gen = get_transcript_generator() + + # Collect messages + messages = [] + async for msg in interaction.channel.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_member(msg.author) + }) + + # Save transcript + filepath = transcript_gen.save_transcript(ticket, messages, format="html") + + if filepath: + await interaction.followup.send( + f"✅ Transcript sauvegardé: `{filepath}`", + ephemeral=True + ) + else: + await interaction.followup.send( + "❌ Erreur lors de la génération du transcript.", + ephemeral=True + ) + + def _is_staff(self, interaction: Interaction) -> bool: + """Check if user is staff""" + guild = interaction.guild + user = interaction.user + + for role_id in self.config.staff_roles: + role = guild.get_role(int(role_id)) + if role and role in user.roles: + return True + + # Also check category-specific roles + storage = get_storage() + ticket = storage.load_ticket(interaction.channel.id) + if ticket: + category = self.config.get_category(ticket.category_id) + if category: + for role_id in category.staff_roles: + role = guild.get_role(int(role_id)) + if role and role in user.roles: + return True + + return False + + def _is_staff_member(self, member) -> bool: + """Check if member is staff""" + guild = member.guild + for role_id in self.config.staff_roles: + role = guild.get_role(int(role_id)) + if role and role in member.roles: + return True + return False + + +class TicketCloseModal(Modal): + """Modal for closing a ticket with reason""" + + def __init__(self, bot, storage, config: GuildTicketConfig, ticket: TicketData): + super().__init__(title="🔒 Fermer le Ticket") + self.bot = bot + self.storage = storage + self.config = config + self.ticket = ticket + + self.reason = TextInput( + label="Raison de la fermeture", + placeholder="Pourquoi fermez-vous ce ticket?", + required=False, + style=discord.TextStyle.paragraph, + custom_id="close_reason" + ) + self.add_item(self.reason) + + async def on_submit(self, interaction: Interaction): + await interaction.response.defer(ephemeral=True) + + # Update ticket + self.ticket.status = TicketStatus.CLOSED + self.ticket.closed_at = datetime.now() + self.storage.save_ticket(self.ticket) + + # Calculate duration + duration_minutes = self.ticket.duration_minutes + + # Send close embed + close_embed = TicketEmbedFormatter.ticket_closed( + self.ticket, + interaction.user, + duration_minutes, + self.reason.value + ) + + # Update channel permissions + overwrites = interaction.channel.overwrites.copy() + for target, perms in overwrites.items(): + if isinstance(target, discord.Member) and target.id != interaction.guild.me.id: + overwrites[target] = discord.PermissionOverwrite( + read_messages=True, + send_messages=False + ) + + await interaction.channel.edit( + name=f"closed-{interaction.channel.name}", + overwrites=overwrites + ) + + # Send close message + await interaction.channel.send( + content=TicketMessageFormatter.ticket_closed_notification( + interaction.user, + self.reason.value + ), + embed=close_embed + ) + + # Show survey if enabled + if self.config.survey_enabled: + survey_view = SurveyView(self.bot, self.storage, self.config, self.ticket) + survey_embed = TicketEmbedFormatter.survey_embed() + await interaction.channel.send(embed=survey_embed, view=survey_view) + + # Log + if self.config.log_channel_id: + log_channel = interaction.guild.get_channel(self.config.log_channel_id) + if log_channel: + log_embed = discord.Embed( + title="🔴 Ticket Fermé", + color=Color.red(), + timestamp=datetime.now() + ) + log_embed.add_field(name="👤 Fermé par", value=interaction.user.mention, inline=True) + log_embed.add_field(name="📝 Salon", value=interaction.channel.mention, inline=True) + log_embed.add_field(name="⏱️ Durée", value=TicketEmbedFormatter._format_duration(duration_minutes), inline=True) + + await log_channel.send(embed=log_embed) + + await interaction.followup.send("Ticket fermé!", ephemeral=True) + + +class TicketPriorityChangeModal(Modal): + """Modal for changing ticket priority""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(title="⚡ Changer la Priorité") + self.bot = bot + self.storage = storage + self.config = config + + self.priority = Select( + placeholder="Sélectionnez la nouvelle priorité", + options=[ + discord.SelectOption(label="Basse", value="low", emoji="🟢"), + discord.SelectOption(label="Normale", value="normal", emoji="🔵"), + discord.SelectOption(label="Haute", value="high", emoji="🟠"), + discord.SelectOption(label="Urgente", value="urgent", emoji="🔴"), + ], + custom_id="priority_change" + ) + self.add_item(self.priority) + + async def on_submit(self, interaction: Interaction): + await interaction.response.defer(ephemeral=True) + + ticket = self.storage.load_ticket(interaction.channel.id) + if not ticket: + await interaction.followup.send("Ticket non trouvé.", ephemeral=True) + return + + priority_map = { + "low": Priority.LOW, + "normal": Priority.NORMAL, + "high": Priority.HIGH, + "urgent": Priority.URGENT + } + + old_priority = ticket.priority + ticket.priority = priority_map[self.priority.values[0]] + self.storage.save_ticket(ticket) + + embed = discord.Embed( + title="⚡ Priorité Changée", + description=f"La priorité a été changée de **{old_priority.value}** à **{ticket.priority.value}**", + color=TicketEmbedFormatter.get_priority_color(ticket.priority) + ) + + await interaction.channel.send(embed=embed) + await interaction.followup.send("Priorité mise à jour!", ephemeral=True) + + +class SurveyView(View): + """View for satisfaction survey""" + + def __init__(self, bot, storage, config: GuildTicketConfig, ticket: TicketData): + super().__init__(timeout=None) # Persistent view + self.bot = bot + self.storage = storage + self.config = config + self.ticket = ticket + + for i in range(1, 6): + btn = Button( + style=ButtonStyle.secondary, + label=f"{i} ⭐", + emoji="⭐" if i == 5 else None, + custom_id=f"survey_{i}" + ) + btn.callback = self._create_rating_callback(i) + self.add_item(btn) + + def _create_rating_callback(self, rating: int): + async def callback(interaction: Interaction): + await self.on_rating(interaction, rating) + return callback + + async def on_rating(self, interaction: Interaction, rating: int): + """Handle rating selection""" + # Only allow ticket creator to rate + if interaction.user.id != self.ticket.user_id: + await interaction.response.send_message( + "Seul le créateur du ticket peut voter.", + ephemeral=True + ) + return + + # Update ticket + self.ticket.rating = rating + self.storage.save_ticket(self.ticket) + + # Show thanks + embed = TicketEmbedFormatter.survey_thanks_embed(rating) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # Disable buttons + for item in self.children: + item.disabled = True + + await interaction.message.edit(view=self) + diff --git a/commandes/whitelist_monitor.py b/commandes/whitelist_monitor.py index 0d4ee25..b798df9 100755 --- a/commandes/whitelist_monitor.py +++ b/commandes/whitelist_monitor.py @@ -12,6 +12,7 @@ class WhitelistMonitor(commands.Cog): def __init__(self, bot): self.bot = bot self.whitelist_file = "data/whitelist.json" + self.persistent_views_added = False self.big_permissions = [ discord.Permissions.administrator, discord.Permissions.manage_guild, @@ -114,6 +115,33 @@ class WhitelistMonitor(commands.Cog): except Exception as e: print(f"Error sending whitelist notification: {e}") + async def cog_load(self): + """Register persistent views when cog is loaded""" + await self._register_persistent_views() + + async def _register_persistent_views(self): + """Register all persistent views for guilds with whitelist alerts""" + if self.persistent_views_added: + return + + try: + import json + security_file = "data/security_settings.json" + if os.path.exists(security_file): + with open(security_file, "r", encoding="utf-8") as f: + all_settings = json.load(f) + + # For each guild with whitelist monitoring, register a default view + # We'll use a generic view that gets recreated when needed + # The key is that each unique custom_id needs to be registered once + view = WhitelistRemovalView(0, 0) # Placeholder with dummy IDs + self.bot.add_view(view) + print(f"[WhitelistMonitor] Persistent view registered") + + self.persistent_views_added = True + except Exception as e: + print(f"[WhitelistMonitor] Error registering views: {e}") + @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """Monitor role changes for whitelist members.""" @@ -170,14 +198,15 @@ class WhitelistRemovalView(View): """View with button to remove user from whitelist.""" def __init__(self, user_id: int, guild_id: int): - super().__init__(timeout=3600) # 1 hour timeout + super().__init__(timeout=None) # Persistent view self.user_id = user_id self.guild_id = guild_id @discord.ui.button( label="Retirer de la whitelist", style=discord.ButtonStyle.red, - emoji="🗑️" + emoji="🗑️", + custom_id="whitelist_remove_button" ) async def remove_whitelist( self,