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
|
|
@ -32,6 +32,26 @@ class Blacklist(commands.Cog):
|
||||||
|
|
||||||
def cog_unload(self):
|
def cog_unload(self):
|
||||||
self.check_ban_expiry.cancel()
|
self.check_ban_expiry.cancel()
|
||||||
|
|
||||||
|
async def cog_load(self):
|
||||||
|
"""Enregistrer les vues persistantes au démarrage"""
|
||||||
|
await self._register_persistent_views()
|
||||||
|
|
||||||
|
async def _register_persistent_views(self):
|
||||||
|
"""Enregistre les vues pour les tickets ouverts"""
|
||||||
|
try:
|
||||||
|
tickets = load_json(TICKETS_FILE, {})
|
||||||
|
for user_id_str, data in tickets.items():
|
||||||
|
user_id = int(user_id_str)
|
||||||
|
# Enregistrer le bouton d'ouverture de ticket
|
||||||
|
view = TicketButton(user_id)
|
||||||
|
self.bot.add_view(view)
|
||||||
|
# Enregistrer le bouton de fermeture
|
||||||
|
close_view = CloseTicketView(user_id)
|
||||||
|
self.bot.add_view(close_view)
|
||||||
|
print(f"[Blacklist] {len(tickets)} vues de tickets enregistrées")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Blacklist] Erreur lors de l'enregistrement des vues: {e}")
|
||||||
|
|
||||||
def is_agent(self, user_id: int):
|
def is_agent(self, user_id: int):
|
||||||
agents = load_json(AGENTS_FILE, {"agents": []})
|
agents = load_json(AGENTS_FILE, {"agents": []})
|
||||||
|
|
@ -180,7 +200,7 @@ class TicketButton(discord.ui.View):
|
||||||
super().__init__(timeout=None)
|
super().__init__(timeout=None)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
@discord.ui.button(label="📩 Ouvrir une demande de révision", style=discord.ButtonStyle.primary)
|
@discord.ui.button(label="📩 Ouvrir une demande de révision", style=discord.ButtonStyle.primary, custom_id="blacklist_open_ticket")
|
||||||
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
return await interaction.response.send_message("❌ Ce bouton ne t’appartient pas.", ephemeral=True)
|
return await interaction.response.send_message("❌ Ce bouton ne t’appartient pas.", ephemeral=True)
|
||||||
|
|
@ -219,7 +239,7 @@ class CloseTicketView(discord.ui.View):
|
||||||
super().__init__(timeout=None)
|
super().__init__(timeout=None)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
@discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger)
|
@discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger, custom_id="blacklist_close_ticket")
|
||||||
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||||
return await interaction.response.send_message("⛔ Seuls les agents peuvent fermer ce ticket.", ephemeral=True)
|
return await interaction.response.send_message("⛔ Seuls les agents peuvent fermer ce ticket.", ephemeral=True)
|
||||||
|
|
@ -232,7 +252,7 @@ class ConfirmCloseView(discord.ui.View):
|
||||||
super().__init__(timeout=30)
|
super().__init__(timeout=30)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
@discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success)
|
@discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success, custom_id="blacklist_confirm_close")
|
||||||
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||||
|
|
@ -270,7 +290,7 @@ class ConfirmCloseView(discord.ui.View):
|
||||||
|
|
||||||
await interaction.response.send_message("✅ Ticket fermé avec succès.", ephemeral=True)
|
await interaction.response.send_message("✅ Ticket fermé avec succès.", ephemeral=True)
|
||||||
|
|
||||||
@discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary)
|
@discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary, custom_id="blacklist_cancel_close")
|
||||||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
await interaction.response.send_message("❌ Fermeture annulée.", ephemeral=True)
|
await interaction.response.send_message("❌ Fermeture annulée.", ephemeral=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,10 @@ class RulesCog(commands.Cog):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.persistent_views_added = False
|
self.persistent_views_added = False
|
||||||
|
|
||||||
|
async def cog_load(self):
|
||||||
|
"""Called when cog is loaded - setup persistent views"""
|
||||||
|
await self.setup_persistent_views()
|
||||||
|
|
||||||
async def setup_persistent_views(self):
|
async def setup_persistent_views(self):
|
||||||
"""Configure les views persistantes pour les règlements existants"""
|
"""Configure les views persistantes pour les règlements existants"""
|
||||||
if self.persistent_views_added:
|
if self.persistent_views_added:
|
||||||
|
|
@ -406,6 +410,7 @@ class RulesCog(commands.Cog):
|
||||||
# Ajouter la view persistente
|
# Ajouter la view persistente
|
||||||
view = RulesAcceptButton(int(guild_id_str), settings["rules_accept_role_id"])
|
view = RulesAcceptButton(int(guild_id_str), settings["rules_accept_role_id"])
|
||||||
self.bot.add_view(view)
|
self.bot.add_view(view)
|
||||||
|
print(f"[Rules] View registered for guild {guild_id_str}")
|
||||||
|
|
||||||
self.persistent_views_added = True
|
self.persistent_views_added = True
|
||||||
except (json.JSONDecodeError, FileNotFoundError):
|
except (json.JSONDecodeError, FileNotFoundError):
|
||||||
|
|
@ -414,7 +419,7 @@ class RulesCog(commands.Cog):
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
"""Appelé quand le bot est prêt"""
|
"""Appelé quand le bot est prêt"""
|
||||||
await self.setup_persistent_views()
|
print("🎫 Module Règles chargé avec succès!")
|
||||||
|
|
||||||
async def send_rules_message(self, channel: discord.TextChannel, settings: dict) -> Optional[discord.Message]:
|
async def send_rules_message(self, channel: discord.TextChannel, settings: dict) -> Optional[discord.Message]:
|
||||||
"""Envoie le message du règlement dans un salon"""
|
"""Envoie le message du règlement dans un salon"""
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,23 @@
|
||||||
import discord
|
"""
|
||||||
from discord.ext import commands, tasks
|
Legacy Ticket Module
|
||||||
from discord.ui import View, Button, Modal, TextInput
|
====================
|
||||||
import json
|
This file now serves as a compatibility wrapper for the new modular ticket system.
|
||||||
|
The main implementation has been moved to the 'commandes/ticket' package.
|
||||||
|
|
||||||
|
To use the new modular system, import from the ticket package:
|
||||||
|
from commandes.ticket import Ticket
|
||||||
|
|
||||||
|
Or use the setup function directly:
|
||||||
|
from commandes.ticket import setup
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# --- Fonctions de gestion de données ---
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
def load_config():
|
# Import from the new modular ticket system
|
||||||
"""Charge la configuration des tickets"""
|
from ticket import setup
|
||||||
path = "data/tickets/config.json"
|
|
||||||
if not os.path.exists("data/tickets"):
|
|
||||||
os.makedirs("data/tickets")
|
|
||||||
if not os.path.exists(path):
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump({}, f, indent=4)
|
|
||||||
with open(path, "r") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def save_config(config):
|
__all__ = ["setup"]
|
||||||
"""Sauvegarde la configuration des tickets"""
|
|
||||||
path = "data/tickets/config.json"
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
|
||||||
|
|
||||||
# --- Vues (Interactions) ---
|
|
||||||
|
|
||||||
class TicketView(View):
|
|
||||||
def __init__(self, bot, config):
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
self.bot = bot
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@discord.ui.button(label="Créer un ticket", style=discord.ButtonStyle.blurple, custom_id="create_ticket")
|
|
||||||
async def create_ticket(self, interaction: discord.Interaction, button: Button):
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
guild_id = str(interaction.guild_id)
|
|
||||||
|
|
||||||
if guild_id not in self.config:
|
|
||||||
return await interaction.followup.send("Le système n'est pas configuré.", ephemeral=True)
|
|
||||||
|
|
||||||
guild_config = self.config[guild_id]
|
|
||||||
|
|
||||||
# Vérifier si l'utilisateur a déjà un ticket
|
|
||||||
for channel in interaction.guild.text_channels:
|
|
||||||
if channel.name.startswith(f"ticket-{interaction.user.id}"):
|
|
||||||
return await interaction.followup.send(f"Vous avez déjà un ticket : {channel.mention}", ephemeral=True)
|
|
||||||
|
|
||||||
# Configuration des permissions
|
|
||||||
category = interaction.guild.get_channel(int(guild_config["category_id"]))
|
|
||||||
overwrites = {
|
|
||||||
interaction.guild.default_role: discord.PermissionOverwrite(read_messages=False),
|
|
||||||
interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True, attach_files=True),
|
|
||||||
interaction.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
}
|
|
||||||
|
|
||||||
for role_id in guild_config["staff_roles"]:
|
|
||||||
role = interaction.guild.get_role(int(role_id))
|
|
||||||
if role:
|
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
|
|
||||||
# Création du salon
|
|
||||||
channel_name = f"ticket-{interaction.user.id}-{datetime.now().strftime('%m%d%H%M')}"
|
|
||||||
ticket_channel = await interaction.guild.create_text_channel(
|
|
||||||
name=channel_name,
|
|
||||||
category=category,
|
|
||||||
overwrites=overwrites,
|
|
||||||
topic=f"Ticket de {interaction.user} ({interaction.user.id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Nouveau Ticket",
|
|
||||||
description=guild_config["ticket_message"],
|
|
||||||
color=discord.Color.blue(),
|
|
||||||
timestamp=datetime.now()
|
|
||||||
)
|
|
||||||
embed.set_author(name=interaction.user.display_name, icon_url=interaction.user.display_avatar.url)
|
|
||||||
|
|
||||||
await ticket_channel.send(embed=embed, view=TicketCloseView(self.bot, self.config))
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
if guild_config.get("log_channel"):
|
|
||||||
log_ch = self.bot.get_channel(int(guild_config["log_channel"]))
|
|
||||||
if log_ch:
|
|
||||||
log_embed = discord.Embed(title="Ticket Créé", color=discord.Color.green())
|
|
||||||
log_embed.add_field(name="Auteur", value=interaction.user.mention)
|
|
||||||
log_embed.add_field(name="Salon", value=ticket_channel.mention)
|
|
||||||
await log_ch.send(embed=log_embed)
|
|
||||||
|
|
||||||
await interaction.followup.send(f"Ticket créé : {ticket_channel.mention}", ephemeral=True)
|
|
||||||
|
|
||||||
class TicketCloseView(View):
|
|
||||||
def __init__(self, bot, config):
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
self.bot = bot
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@discord.ui.button(label="Fermer le ticket", style=discord.ButtonStyle.red, custom_id="close_ticket")
|
|
||||||
async def close_ticket(self, interaction: discord.Interaction, button: Button):
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
guild_config = self.config.get(str(interaction.guild_id))
|
|
||||||
|
|
||||||
# Modifier permissions (bloquer l'écriture)
|
|
||||||
overwrites = interaction.channel.overwrites
|
|
||||||
for target in overwrites:
|
|
||||||
if isinstance(target, discord.Member) and target != interaction.guild.me:
|
|
||||||
overwrites[target] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
|
|
||||||
|
|
||||||
await interaction.channel.edit(name=f"closed-{interaction.channel.name}", overwrites=overwrites)
|
|
||||||
|
|
||||||
embed = discord.Embed(title="Ticket Fermé", description=f"Fermé par {interaction.user.mention}", color=discord.Color.red())
|
|
||||||
await interaction.channel.send(embed=embed, view=TicketReopenView(self.bot, self.config))
|
|
||||||
await interaction.followup.send("Ticket fermé.", ephemeral=True)
|
|
||||||
|
|
||||||
class TicketReopenView(View):
|
|
||||||
def __init__(self, bot, config):
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
self.bot = bot
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@discord.ui.button(label="Rouvrir", style=discord.ButtonStyle.green, custom_id="reopen_ticket")
|
|
||||||
async def reopen(self, interaction: discord.Interaction, button: Button):
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
new_name = interaction.channel.name.replace("closed-", "")
|
|
||||||
await interaction.channel.edit(name=new_name)
|
|
||||||
await interaction.channel.send(f"Ticket rouvert par {interaction.user.mention}", view=TicketCloseView(self.bot, self.config))
|
|
||||||
|
|
||||||
@discord.ui.button(label="Supprimer", style=discord.ButtonStyle.grey, custom_id="delete_ticket")
|
|
||||||
async def delete(self, interaction: discord.Interaction, button: Button):
|
|
||||||
await interaction.response.send_message("Suppression dans 3s...")
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
await interaction.channel.delete()
|
|
||||||
|
|
||||||
# --- Modale de Configuration ---
|
|
||||||
|
|
||||||
class TicketConfigModal(Modal):
|
|
||||||
def __init__(self, bot, config, guild_id):
|
|
||||||
super().__init__(title="Configuration Tickets")
|
|
||||||
self.bot = bot
|
|
||||||
self.config = config
|
|
||||||
self.guild_id = str(guild_id)
|
|
||||||
|
|
||||||
self.category_id = TextInput(label="ID Catégorie", placeholder="Ex: 123456789", required=True)
|
|
||||||
self.staff_roles = TextInput(label="IDs Rôles Staff (virgule)", placeholder="ID1, ID2", required=True)
|
|
||||||
self.log_channel = TextInput(label="ID Salon Logs", required=False)
|
|
||||||
self.ticket_msg = TextInput(label="Message Ticket", style=discord.TextStyle.paragraph, required=True)
|
|
||||||
self.welcome_msg = TextInput(label="Message Panel", required=True)
|
|
||||||
|
|
||||||
for item in [self.category_id, self.staff_roles, self.log_channel, self.ticket_msg, self.welcome_msg]:
|
|
||||||
self.add_item(item)
|
|
||||||
|
|
||||||
async def on_submit(self, interaction: discord.Interaction):
|
|
||||||
role_ids = [r.strip() for r in self.staff_roles.value.split(",")]
|
|
||||||
self.config[self.guild_id] = {
|
|
||||||
"category_id": self.category_id.value,
|
|
||||||
"staff_roles": role_ids,
|
|
||||||
"log_channel": self.log_channel.value,
|
|
||||||
"ticket_message": self.ticket_msg.value,
|
|
||||||
"welcome_message": self.welcome_msg.value
|
|
||||||
}
|
|
||||||
save_config(self.config)
|
|
||||||
await interaction.response.send_message("Configuration sauvegardée !", ephemeral=True)
|
|
||||||
|
|
||||||
# --- Le Cog Principal ---
|
|
||||||
|
|
||||||
class Ticket(commands.Cog):
|
|
||||||
def __init__(self, bot):
|
|
||||||
self.bot = bot
|
|
||||||
self.config = load_config()
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_ready(self):
|
|
||||||
# Enregistrer les vues persistantes (pour qu'elles marchent après reboot)
|
|
||||||
self.bot.add_view(TicketView(self.bot, self.config))
|
|
||||||
self.bot.add_view(TicketCloseView(self.bot, self.config))
|
|
||||||
self.bot.add_view(TicketReopenView(self.bot, self.config))
|
|
||||||
print("Module Ticket prêt")
|
|
||||||
|
|
||||||
@commands.hybrid_group(name="ticket")
|
|
||||||
@commands.has_permissions(administrator=True)
|
|
||||||
async def ticket(self, ctx):
|
|
||||||
if ctx.invoked_subcommand is None:
|
|
||||||
await ctx.send("Utilisez `/ticket setup` ou `/ticket panel`", ephemeral=True)
|
|
||||||
|
|
||||||
@ticket.command(name="setup")
|
|
||||||
async def setup_cmd(self, ctx):
|
|
||||||
await ctx.interaction.response.send_modal(TicketConfigModal(self.bot, self.config, ctx.guild.id))
|
|
||||||
|
|
||||||
@ticket.command(name="panel")
|
|
||||||
async def panel_cmd(self, ctx, channel: discord.TextChannel = None):
|
|
||||||
guild_id = str(ctx.guild.id)
|
|
||||||
if guild_id not in self.config:
|
|
||||||
return await ctx.send("Configurez d'abord le système via `/ticket setup`.")
|
|
||||||
|
|
||||||
target = channel or ctx.channel
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Support",
|
|
||||||
description=self.config[guild_id]["welcome_message"],
|
|
||||||
color=discord.Color.blue()
|
|
||||||
)
|
|
||||||
await target.send(embed=embed, view=TicketView(self.bot, self.config))
|
|
||||||
await ctx.send("Panel envoyé !", ephemeral=True)
|
|
||||||
|
|
||||||
async def setup(bot):
|
|
||||||
await bot.add_cog(Ticket(bot))
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ class WhitelistMonitor(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.whitelist_file = "data/whitelist.json"
|
self.whitelist_file = "data/whitelist.json"
|
||||||
|
self.persistent_views_added = False
|
||||||
self.big_permissions = [
|
self.big_permissions = [
|
||||||
discord.Permissions.administrator,
|
discord.Permissions.administrator,
|
||||||
discord.Permissions.manage_guild,
|
discord.Permissions.manage_guild,
|
||||||
|
|
@ -114,6 +115,33 @@ class WhitelistMonitor(commands.Cog):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error sending whitelist notification: {e}")
|
print(f"Error sending whitelist notification: {e}")
|
||||||
|
|
||||||
|
async def cog_load(self):
|
||||||
|
"""Register persistent views when cog is loaded"""
|
||||||
|
await self._register_persistent_views()
|
||||||
|
|
||||||
|
async def _register_persistent_views(self):
|
||||||
|
"""Register all persistent views for guilds with whitelist alerts"""
|
||||||
|
if self.persistent_views_added:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
security_file = "data/security_settings.json"
|
||||||
|
if os.path.exists(security_file):
|
||||||
|
with open(security_file, "r", encoding="utf-8") as f:
|
||||||
|
all_settings = json.load(f)
|
||||||
|
|
||||||
|
# For each guild with whitelist monitoring, register a default view
|
||||||
|
# We'll use a generic view that gets recreated when needed
|
||||||
|
# The key is that each unique custom_id needs to be registered once
|
||||||
|
view = WhitelistRemovalView(0, 0) # Placeholder with dummy IDs
|
||||||
|
self.bot.add_view(view)
|
||||||
|
print(f"[WhitelistMonitor] Persistent view registered")
|
||||||
|
|
||||||
|
self.persistent_views_added = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WhitelistMonitor] Error registering views: {e}")
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||||
"""Monitor role changes for whitelist members."""
|
"""Monitor role changes for whitelist members."""
|
||||||
|
|
@ -170,14 +198,15 @@ class WhitelistRemovalView(View):
|
||||||
"""View with button to remove user from whitelist."""
|
"""View with button to remove user from whitelist."""
|
||||||
|
|
||||||
def __init__(self, user_id: int, guild_id: int):
|
def __init__(self, user_id: int, guild_id: int):
|
||||||
super().__init__(timeout=3600) # 1 hour timeout
|
super().__init__(timeout=None) # Persistent view
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.guild_id = guild_id
|
self.guild_id = guild_id
|
||||||
|
|
||||||
@discord.ui.button(
|
@discord.ui.button(
|
||||||
label="Retirer de la whitelist",
|
label="Retirer de la whitelist",
|
||||||
style=discord.ButtonStyle.red,
|
style=discord.ButtonStyle.red,
|
||||||
emoji="🗑️"
|
emoji="🗑️",
|
||||||
|
custom_id="whitelist_remove_button"
|
||||||
)
|
)
|
||||||
async def remove_whitelist(
|
async def remove_whitelist(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue