""" 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))