Kuby/commandes/ticket/__init__.py

661 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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