Première version non terminer du nouveau système de ticket § ajout du système de boutons persistant (non tester)

This commit is contained in:
Mathis 2026-01-04 00:01:15 +01:00
parent 10173132a2
commit 93e2f9bb44
15 changed files with 3788 additions and 209 deletions

130
commandes/ticket/TODO.md Normal file
View file

@ -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

View file

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

View file

@ -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",
]

View file

@ -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

View file

@ -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

View file

@ -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",
]

View file

@ -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}"

View file

@ -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", "<br>")
is_staff = msg.get("is_staff", False)
staff_class = " staff-message" if is_staff else ""
staff_badge = '<span class="staff-badge">STAFF</span>' if is_staff else ""
html_messages += f"""
<div class="message{staff_class}">
<div class="message-header">
<span class="author">{author}</span>
{staff_badge}
<span class="timestamp">{timestamp}</span>
</div>
<div class="message-content">{content}</div>
</div>
"""
# 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"""
<div class="stats-section">
<h3>📊 Statistiques</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Durée</span>
<span class="stat-value">{duration}</span>
</div>
<div class="stat-item">
<span class="stat-label">Messages</span>
<span class="stat-value">{len(messages) if messages else len(ticket.messages)}</span>
</div>
<div class="stat-item">
<span class="stat-label">Note</span>
<span class="stat-value">{rating_stars}</span>
</div>
</div>
</div>
"""
html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transcript - Ticket #{ticket.channel_id}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f6fa;
color: #2c3e50;
line-height: 1.6;
}}
.container {{
max-width: 900px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, {priority_color}, #34495e);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 20px;
}}
.header h1 {{
font-size: 24px;
margin-bottom: 10px;
}}
.header-meta {{
display: flex;
flex-wrap: wrap;
gap: 20px;
font-size: 14px;
opacity: 0.9;
}}
.meta-item {{
display: flex;
align-items: center;
gap: 5px;
}}
.status-badge {{
display: inline-block;
padding: 3px 10px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}}
.status-open {{ background: #27ae60; }}
.status-closed {{ background: #e74c3c; }}
.status-claimed {{ background: #f39c12; }}
.messages-section {{
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.messages-section h3 {{
margin-bottom: 15px;
color: {priority_color};
border-bottom: 2px solid #ecf0f1;
padding-bottom: 10px;
}}
.message {{
padding: 15px;
border-bottom: 1px solid #ecf0f1;
}}
.message:last-child {{
border-bottom: none;
}}
.message.staff-message {{
background: #f8f9fa;
border-left: 3px solid {priority_color};
}}
.message-header {{
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}}
.author {{
font-weight: bold;
color: #2c3e50;
}}
.staff-badge {{
background: {priority_color};
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
}}
.timestamp {{
color: #95a5a6;
font-size: 12px;
}}
.message-content {{
color: #34495e;
word-break: break-word;
}}
.stats-section {{
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.stats-section h3 {{
margin-bottom: 15px;
color: {priority_color};
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}}
.stat-item {{
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
text-align: center;
}}
.stat-label {{
display: block;
font-size: 12px;
color: #7f8c8d;
margin-bottom: 5px;
}}
.stat-value {{
font-size: 18px;
font-weight: bold;
color: {priority_color};
}}
.footer {{
text-align: center;
padding: 20px;
color: #95a5a6;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎫 Transcript de Ticket</h1>
<div class="header-meta">
<div class="meta-item">
<span>📌 ID:</span>
<span>{ticket.channel_id}</span>
</div>
<div class="meta-item">
<span>📁 Catégorie:</span>
<span>{ticket.category_id}</span>
</div>
<div class="meta-item">
<span> Priorité:</span>
<span style="color: {priority_color}; font-weight: bold;">{ticket.priority.value.upper()}</span>
</div>
<div class="meta-item">
<span>📊 Statut:</span>
<span class="status-badge status-{ticket.status.value}">{ticket.status.value}</span>
</div>
<div class="meta-item">
<span>📅 Créé:</span>
<span>{ticket.created_at.strftime('%d/%m/%Y %H:%M')}</span>
</div>
</div>
</div>
<div class="messages-section">
<h3>💬 Messages ({len(messages) if messages else len(ticket.messages)})</h3>
{html_messages}
</div>
{stats_html}
<div class="footer">
<p>Généré le {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}</p>
<p>Système de Tickets - Kuby Bot</p>
</div>
</div>
</body>
</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

View file

@ -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",
]

View file

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

View file

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