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:
parent
10173132a2
commit
93e2f9bb44
15 changed files with 3788 additions and 209 deletions
130
commandes/ticket/TODO.md
Normal file
130
commandes/ticket/TODO.md
Normal 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
|
||||
|
||||
661
commandes/ticket/__init__.py
Normal file
661
commandes/ticket/__init__.py
Normal 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))
|
||||
|
||||
29
commandes/ticket/data/__init__.py
Normal file
29
commandes/ticket/data/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
353
commandes/ticket/data/models.py
Normal file
353
commandes/ticket/data/models.py
Normal 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
|
||||
|
||||
323
commandes/ticket/data/storage.py
Normal file
323
commandes/ticket/data/storage.py
Normal 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
|
||||
|
||||
15
commandes/ticket/utils/__init__.py
Normal file
15
commandes/ticket/utils/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
453
commandes/ticket/utils/formatters.py
Normal file
453
commandes/ticket/utils/formatters.py
Normal 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}"
|
||||
|
||||
496
commandes/ticket/utils/transcript.py
Normal file
496
commandes/ticket/utils/transcript.py
Normal 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
|
||||
|
||||
35
commandes/ticket/views/__init__.py
Normal file
35
commandes/ticket/views/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
522
commandes/ticket/views/admin.py
Normal file
522
commandes/ticket/views/admin.py
Normal 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
|
||||
)
|
||||
|
||||
692
commandes/ticket/views/creation.py
Normal file
692
commandes/ticket/views/creation.py
Normal 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)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue