diff --git "a/commandes/ticket/__init__.py" "b/commandes/ticket/__init__.py" new file mode 100644 index 0000000..e69de29 diff --git a/bot.py b/bot.py index 253467e..d8606bc 100755 --- a/bot.py +++ b/bot.py @@ -31,7 +31,7 @@ class MyBot(commands.Bot): def __init__(self): kuby_logger.debug("Initializing MyBot class") super().__init__( - command_prefix="!", + command_prefix="!", intents=intents, application_id=application_id, ) @@ -84,7 +84,7 @@ class MyBot(commands.Bot): kuby_logger.warning("WhitelistMonitor cog not found, allowing all commands") return True - is_whitelisted = whitelist_cog.is_whitelisted(ctx.author.id) + is_whitelisted = whitelist_cog.is_whitelisted(ctx.guild.id, ctx.author.id) if not is_whitelisted: kuby_logger.info(f"User {ctx.author} (ID: {ctx.author.id}) attempted to use command but is not whitelisted") diff --git a/commandes/ticket/FIX_PLAN.md b/commandes/ticket/FIX_PLAN.md new file mode 100644 index 0000000..749195a --- /dev/null +++ b/commandes/ticket/FIX_PLAN.md @@ -0,0 +1,20 @@ +# Fix Plan: Missing _config_roles_callback method + +## Problem +The `TicketSetupView` class has a button that references `_config_roles_callback`, but this method doesn't exist. This causes an `AttributeError` when the `/ticketconfig` command is used. + +## Solution +1. Add `_config_roles_callback` method to `TicketSetupView` class +2. Create `ConfigRolesSetupView` class to handle config roles selection + +## Files to modify +- `commandes/ticket/__init__.py` + +## Changes +1. Add `_config_roles_callback` method in `TicketSetupView` (after `_categories_callback`) +2. Add `ConfigRolesSetupView` class (after `StaffRolesSetupView`) + +## Status +- [x] Add `_config_roles_callback` method +- [x] Add `ConfigRolesSetupView` class + diff --git a/commandes/ticket/TODO.md b/commandes/ticket/TODO.md index bec0d09..8272047 100644 --- a/commandes/ticket/TODO.md +++ b/commandes/ticket/TODO.md @@ -1,130 +1,54 @@ -# đŸŽ« Ticket System - TODO & Documentation +# Ticket System Fixes - Implementation Status -## Vue d'ensemble -SystĂšme de tickets modulaire et complet pour Discord bot. +## ✅ Completed Fixes -## 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 -``` +### 1. UI Synchronization Issue +- **Problem**: TicketManagementView._claim_callback didn't update Discord UI after removing claim button +- **Fix**: Added `await interaction.response.edit_message(view=self)` after view modification +- **Status**: ✅ FIXED -## ⚠ Vues Persistantes - Important +### 2. Resource Leak and API Overload +- **Problem**: @tasks.loop(seconds=30) on _register_views caused rate limits and memory consumption +- **Fix**: Moved view registration to cog_load() method, removed looped task +- **Status**: ✅ FIXED -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()` +### 3. Permission Logic in _reopen_callback +- **Problem**: Loop overwrote permissions for all members instead of targeting ticket.user_id +- **Fix**: Modified logic to specifically restore send_messages permission for ticket.user_id only +- **Status**: ✅ FIXED -### 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 +### 4. Missing Error Handling in Modals +- **Problem**: TicketPriorityModal and CloseTicketModal lacked on_error methods +- **Fix**: Added try/except blocks in on_submit and implemented on_error methods +- **Status**: ✅ FIXED -### Enregistrement des vues -Les vues sont enregistrĂ©es dans `cog_load()` et aussi pĂ©riodiquement toutes les 30 secondes: +### 5. Input Validation in StaffRolesModal +- **Problem**: Regex (\d{17,20}) was too permissive, didn't validate role existence +- **Fix**: Added validation to check if extracted role IDs correspond to existing guild roles +- **Status**: ✅ FIXED -```python -async def cog_load(self): - await self._register_all_views() +### 6. Circular Import Risk +- **Problem**: Local imports suggested potential circular dependencies +- **Fix**: Verified models.py doesn't import from __init__.py - no circular import exists +- **Status**: ✅ VERIFIED (No fix needed) -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. -``` +## đŸ§Ș Testing Recommendations -## Commandes +1. **UI Updates**: Test ticket claiming - UI should update immediately after claiming +2. **Performance**: Monitor API usage - should be significantly reduced +3. **Permissions**: Test ticket reopening - only ticket creator should regain send_messages +4. **Error Handling**: Trigger errors in modals to verify graceful failure +5. **Role Validation**: Try invalid role IDs in staff configuration +6. **Import Safety**: Verify no import errors on bot startup -| 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 +## 📋 Files Modified +- `commandes/ticket/__init__.py`: All fixes applied +- `commandes/ticket/data/models.py`: Verified for circular imports (none found) +## 🎯 Impact +- Improved user experience with immediate UI updates +- Reduced API load and memory usage +- Enhanced security with proper permission handling +- Better error resilience +- More robust input validation +- Maintained clean architecture diff --git a/commandes/ticket/TODO_new.md b/commandes/ticket/TODO_new.md new file mode 100644 index 0000000..e69de29 diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 5774e42..cc408b6 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -1,250 +1,308 @@ """ -Modular Ticket System -===================== -A complete, beautiful, and modular ticket system for Discord bots. +SystĂšme de Tickets SimplifiĂ© +============================= +Un systĂšme de tickets simple et intuitif avec panel intĂ©grĂ©. -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 +FonctionnalitĂ©s: +- Une seule commande: /ticket +- CrĂ©ation de tickets via boutons +- Gestion via interface interactive +- Stats et configuration accessibles depuis le mĂȘme panel """ import os import sys +import io +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +from collections import defaultdict # 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 PIL import Image, ImageDraw, ImageFont from .data.storage import get_storage -from .data.models import GuildTicketConfig, TicketCategory, TicketStatus, Priority +from .data.models import ( + GuildTicketConfig, + TicketCategory, + TicketStatus, + Priority, + TicketData, + TicketStats +) 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, -) +from .staff_analytics import StaffAnalytics, StaffStatsView -class Ticket(commands.Cog): - """ - Main ticket cog with all functionality. - """ - - def __init__(self, bot): +class TicketPanelView(discord.ui.View): + """Panel principal avec toutes les options intĂ©grĂ©es""" + + def __init__(self, bot, config: GuildTicketConfig, storage=None): + super().__init__(timeout=None) self.bot = bot - self.storage = get_storage() - self._register_views.start() - self._auto_close_check.start() + self.config = config + self.storage = storage or get_storage() + self.PURPLE = discord.Color.blurple() + + # Boutons du panel + self._build_panel() - 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() + def _build_panel(self): + """Construit les boutons du panel selon la configuration""" + self.clear_items() + + # CrĂ©er ticket + create_btn = discord.ui.Button( + style=discord.ButtonStyle.blurple, + label="đŸŽ« CrĂ©er un ticket", + custom_id="ticket_create" ) - 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) + create_btn.callback = self._create_callback + self.add_item(create_btn) + + # Mes tickets (si l'utilisateur a des tickets) + my_tickets_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="📂 Mes tickets", + custom_id="ticket_my_tickets" + ) + my_tickets_btn.callback = self._my_tickets_callback + self.add_item(my_tickets_btn) + + # Analytics (staff only) + analytics_btn = discord.ui.Button( + style=discord.ButtonStyle.primary, + label="📊 Analytics", + custom_id="ticket_analytics" + ) + analytics_btn.callback = self._analytics_callback + self.add_item(analytics_btn) + + # Configuration (admin only) + config_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="⚙ Configuration", + custom_id="ticket_config" + ) + config_btn.callback = self._config_callback + self.add_item(config_btn) - 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) + async def _create_callback(self, interaction: discord.Interaction): + """Affiche les catĂ©gories pour crĂ©er un ticket""" + if not self._is_whitelisted(interaction): + await interaction.response.send_message("❌ Vous n'ĂȘtes pas autorisĂ© Ă  utiliser cette fonctionnalitĂ©. Veuillez demander Ă  ĂȘtre ajoutĂ© Ă  la whitelist.", ephemeral=True) + return + + if not self.config.enabled: + await interaction.response.send_message("Le systĂšme de tickets est dĂ©sactivĂ©.", ephemeral=True) + return + + if not self.config.categories: + await interaction.response.send_message("Aucune catĂ©gorie configurĂ©e. Utilisez la configuration.", ephemeral=True) return - if not config.categories: - await ctx.send("Aucune catĂ©gorie configurĂ©e. Utilisez `/ticket setup` d'abord.", ephemeral=True) + # CrĂ©er view avec les catĂ©gories + view = CategorySelectionView(self.bot, self.config) + + embed = discord.Embed( + title="CrĂ©er un Ticket", + description="SĂ©lectionnez une catĂ©gorie ci-dessous:", + color=discord.Color.blurple() + ) + + for cat in self.config.categories[:5]: + embed.add_field( + name=f"{cat.emoji} {cat.name}", + value=cat.description or "Aucune description", + inline=True + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + async def _my_tickets_callback(self, interaction: discord.Interaction): + """Affiche les tickets de l'utilisateur""" + if not self._is_whitelisted(interaction): + await interaction.response.send_message("❌ Vous n'ĂȘtes pas autorisĂ© Ă  utiliser cette fonctionnalitĂ©. Veuillez demander Ă  ĂȘtre ajoutĂ© Ă  la whitelist.", 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) + tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True) if not tickets: - await ctx.send("Aucun ticket trouvĂ©.", ephemeral=True) + await interaction.response.send_message("Vous n'avez aucun ticket ouvert.", ephemeral=True) return - embed = TicketEmbedFormatter.ticket_list_embed(tickets, user) + embed = discord.Embed( + title="Vos Tickets Ouverts", + description=f"Vous avez **{len(tickets)}** ticket(s) ouvert(s):", + color=discord.Color.blue() + ) - await ctx.send(embed=embed, ephemeral=True) + for ticket in tickets[:5]: + channel = interaction.guild.get_channel(ticket.channel_id) + channel_mention = channel.mention if channel else "Salon supprimĂ©" + embed.add_field( + name=f"Ticket #{ticket.channel_id}", + value=f"Salon: {channel_mention}\nStatut: {ticket.status.value}\nCréé: {ticket.created_at.strftime('%d/%m/%Y')}", + inline=True + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + async def _analytics_callback(self, interaction: discord.Interaction): + """Affiche les analytics (staff uniquement)""" + if not self._is_staff(interaction): + await interaction.response.send_message("❌ Cette fonctionnalitĂ© est rĂ©servĂ©e au staff.", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + analytics = StaffAnalytics(self.bot) + + # Get staff list for individual stats + staff_list = await analytics.get_staff_list(interaction.guild_id) + + # Create main embed with overall stats + embed = discord.Embed( + title="📊 Analytics des Tickets", + description="Statistiques dĂ©taillĂ©es des tickets et performances du staff", + color=discord.Color.blue(), + timestamp=datetime.now() + ) + + # Calculate overall stats + tickets = self.storage.get_guild_tickets(interaction.guild_id) + total_tickets = len(tickets) + open_tickets = sum(1 for t in tickets if t.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]) + closed_tickets = sum(1 for t in tickets if t.status == TicketStatus.CLOSED) + claimed_tickets = sum(1 for t in tickets if t.claimed_by is not None) + + # Basic stats + embed.add_field( + name="📈 Statistiques GĂ©nĂ©rales", + value=f"**Total:** {total_tickets}\n" + f"**Ouverts:** {open_tickets}\n" + f"**FermĂ©s:** {closed_tickets}\n" + f"**RĂ©clamĂ©s:** {claimed_tickets}", + inline=True + ) + + # Staff performance summary + if staff_list: + total_ratings = sum(s['total_ratings'] for s in staff_list) + avg_rating = sum(s['average_rating'] for s in staff_list if s['total_ratings'] > 0) / len([s for s in staff_list if s['total_ratings'] > 0]) if any(s['total_ratings'] > 0 for s in staff_list) else 0 + + embed.add_field( + name="đŸ‘„ Équipe Staff", + value=f"**Membres actifs:** {len(staff_list)}\n" + f"**Évaluations totales:** {total_ratings}\n" + f"**Note moyenne globale:** {avg_rating:.1f}⭐" if avg_rating > 0 else "**Note moyenne globale:** N/A", + inline=True + ) + + # Send main embed + await interaction.followup.send(embed=embed, ephemeral=True) + + # Create and send staff rating chart + try: + if staff_list: + rating_chart = analytics.create_staff_rating_chart(staff_list) + await interaction.followup.send( + "📊 **Évaluations des Membres du Staff**", + file=discord.File(rating_chart, 'staff_ratings.png'), + ephemeral=True + ) + + # Add individual staff stats view + stats_view = StaffStatsView(self.bot, staff_list, analytics) + stats_embed = discord.Embed( + title="📈 Statistiques Individuelles", + description="Cliquez sur un membre du staff pour voir ses statistiques dĂ©taillĂ©es:", + color=discord.Color.purple() + ) + await interaction.followup.send(embed=stats_embed, view=stats_view, ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"❌ Erreur lors de la gĂ©nĂ©ration des graphiques: {e}", ephemeral=True) + + + + async def _config_callback(self, interaction: discord.Interaction): + """Ouvre le panel de configuration (admin ou rĂŽles config uniquement)""" + if not self._is_whitelisted(interaction): + await interaction.response.send_message("❌ Vous n'ĂȘtes pas autorisĂ© Ă  utiliser cette fonctionnalitĂ©. Veuillez demander Ă  ĂȘtre ajoutĂ© Ă  la whitelist.", ephemeral=True) + return + + if not self._can_access_config(interaction): + await interaction.response.send_message("❌ Cette fonctionnalitĂ© est rĂ©servĂ©e aux administrateurs ou aux rĂŽles configurĂ©s.", ephemeral=True) + return + + storage = get_storage() + config_view = ConfigPanelView(self.bot, storage, self.config) + embed = discord.Embed( + title="Configuration des Tickets", + description="GĂ©rez la configuration du systĂšme de tickets", + color=discord.Color.blue() + ) + + await interaction.response.send_message(embed=embed, view=config_view, ephemeral=True) + + + def _is_staff(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est staff""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + 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 + + return False + + def _is_admin(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est administrateur""" + return interaction.user.guild_permissions.administrator + + def _can_access_config(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur peut accĂ©der Ă  la configuration""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + # VĂ©rifier les rĂŽles 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 + + return False + + def _is_whitelisted(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est whitelistĂ© ou a les permissions staff/config""" + # Staff members and config roles can always access + if self._is_staff(interaction) or self._can_access_config(interaction): + return True + + # Get whitelist monitor cog + whitelist_cog = self.bot.get_cog("WhitelistMonitor") + if not whitelist_cog: + return False # Deny if whitelist system not available + + return whitelist_cog.is_whitelisted(interaction.guild.id, interaction.user.id) - # --- 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 - + def _calculate_stats(self, guild_id: int) -> TicketStats: + """Calcule les statistiques""" storage = get_storage() tickets = storage.get_guild_tickets(guild_id) @@ -266,12 +324,6 @@ class Ticket(commands.Cog): 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 @@ -286,47 +338,354 @@ class Ticket(commands.Cog): stats.average_rating = rating_sum / rating_count return stats + + +class CategorySelectionView(discord.ui.View): + """SĂ©lection de catĂ©gorie pour crĂ©er un ticket""" + + def __init__(self, bot, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.config = config + + # Ajouter boutons de catĂ©gories + for category in config.categories[:5]: + btn = discord.ui.Button( + style=discord.ButtonStyle.blurple, + label=f"{category.emoji} {category.name}", + custom_id=f"cat_{category.id}" + ) + btn.callback = self._category_callback(category.id) + self.add_item(btn) + + def _is_staff(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est staff""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + 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 + + return False + + def _can_access_config(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur peut accĂ©der Ă  la configuration""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + # VĂ©rifier les rĂŽles 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 + + return False + + def _is_whitelisted(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est whitelistĂ© ou a les permissions staff/config""" + # Staff members and config roles can always access + if self._is_staff(interaction) or self._can_access_config(interaction): + return True + + # Get whitelist monitor cog + whitelist_cog = self.bot.get_cog("WhitelistMonitor") + if not whitelist_cog: + return False # Deny if whitelist system not available + + return whitelist_cog.is_whitelisted(interaction.guild.id, interaction.user.id) + + def _is_staff(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est staff""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + 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 + + return False + + def _can_access_config(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur peut accĂ©der Ă  la configuration""" + # Admin peut toujours accĂ©der + if interaction.user.guild_permissions.administrator: + return True + + # VĂ©rifier les rĂŽles 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 + + return False + + def _is_whitelisted(self, interaction: discord.Interaction) -> bool: + """VĂ©rifie si l'utilisateur est whitelistĂ© ou a les permissions staff/config""" + # Staff members and config roles can always access + if self._is_staff(interaction) or self._can_access_config(interaction): + return True + + # Get whitelist monitor cog + whitelist_cog = self.bot.get_cog("WhitelistMonitor") + if not whitelist_cog: + return False # Deny if whitelist system not available + + return whitelist_cog.is_whitelisted(interaction.guild.id, interaction.user.id) - # --- Admin Commands --- + def _category_callback(self, category_id: str): + async def callback(interaction: discord.Interaction): + await self._on_category_select(interaction, category_id) + return callback - @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) + async def _on_category_select(self, interaction: discord.Interaction, category_id: str): + """Handle category selection""" + if not self._is_whitelisted(interaction): + await interaction.response.send_message("❌ Vous n'ĂȘtes pas autorisĂ© Ă  utiliser cette fonctionnalitĂ©. Veuillez demander Ă  ĂȘtre ajoutĂ© Ă  la whitelist.", ephemeral=True) + return + + category = self.config.get_category(category_id) + if not category: + await interaction.response.send_message("CatĂ©gorie non trouvĂ©e.", ephemeral=True) return + storage = get_storage() + + # Check limits + user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True) + + if len(user_tickets) >= self.config.max_tickets_per_user: + await interaction.response.send_message( + f"Vous avez atteint la limite de {self.config.max_tickets_per_user} tickets.", + ephemeral=True + ) + return + + # Show priority modal + modal = TicketPriorityModal(self.bot, self.config, category) + await interaction.response.send_modal(modal) + + +class TicketPriorityModal(discord.ui.Modal): + """Modal pour sĂ©lectionner la prioritĂ© et la raison""" + + def __init__(self, bot, config: GuildTicketConfig, category: TicketCategory): + super().__init__(title=f" {category.name}") + self.bot = bot + self.config = config + self.category = category + + # PrioritĂ© + self.priority = discord.ui.TextInput( + label="PrioritĂ©", + placeholder="Normale, Haute ou Urgente", + default="Normale", + required=True, + max_length=10 + ) + self.add_item(self.priority) + + # Raison + self.reason = discord.ui.TextInput( + label="Motif de votre demande", + placeholder="DĂ©crivez votre demande...", + style=discord.TextStyle.paragraph, + required=True, + max_length=500 + ) + self.add_item(self.reason) + + async def on_submit(self, interaction: discord.Interaction): + try: + # Map priority + priority_map = { + "Urgente": Priority.URGENT, + "Haute": Priority.HIGH, + "Normale": Priority.NORMAL + } + priority = priority_map.get(self.priority.value, Priority.NORMAL) + + # Create ticket + await self._create_ticket(interaction, priority, self.reason.value) + except Exception as e: + await interaction.response.send_message(f"Erreur lors de la crĂ©ation du ticket: {e}", ephemeral=True) + + async def on_error(self, interaction: discord.Interaction, error: Exception): + """Handle errors in the modal""" + await interaction.response.send_message("Une erreur inattendue s'est produite lors de la crĂ©ation du ticket.", ephemeral=True) + + async def _create_ticket(self, interaction: discord.Interaction, priority: Priority, reason: str): + """CrĂ©e le ticket""" + guild = interaction.guild + user = interaction.user + + storage = get_storage() + + # Build permissions + overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + user: discord.PermissionOverwrite(read_messages=True, send_messages=True), + guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True) + } + + # Add 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 using username and category without numbers + safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip() + if not safe_username: + safe_username = "user" + safe_category = "".join(c for c in self.category.name if not c.isdigit()).strip() + channel_name = f"{safe_username}-{safe_category}" + + try: + category_channel = None + if self.config.default_category: + try: + category_channel = guild.get_channel(int(self.config.default_category)) + except (ValueError, TypeError): + pass + + ticket_channel = await guild.create_text_channel( + name=channel_name, + category=category_channel, + overwrites=overwrites, + topic=f"Ticket de {user} | CatĂ©gorie: {self.category.name}" + ) + except Exception as e: + await interaction.response.send_message(f"Erreur: {e}", ephemeral=True) + return + + # Create ticket data + ticket_data = TicketData( + channel_id=ticket_channel.id, + guild_id=guild.id, + user_id=user.id, + category_id=self.category.id, + priority=priority, + status=TicketStatus.OPEN, + reason=reason + ) + storage.save_ticket(ticket_data) + + # Send welcome message + embed = discord.Embed( + title=f" {self.category.emoji} {self.category.name}", + description=f"Bienvenue {user.mention}!\n\nVotre demande sera traitĂ©e sous peu par notre Ă©quipe.", + color=discord.Color.blurple() + ) + embed.add_field(name="Motif", value=reason, inline=False) + embed.add_field(name="PrioritĂ©", value=priority.value.capitalize(), inline=True) + embed.set_footer(text=f"Ticket #{ticket_channel.id}") + + # Add management buttons + view = TicketManagementView(self.bot, storage, self.config, ticket_data) + await ticket_channel.send(content=user.mention, embed=embed, view=view) + + # Log + if self.config.log_channel_id: + log_channel = guild.get_channel(self.config.log_channel_id) + if log_channel: + log_embed = discord.Embed( + title="Nouveau Ticket", + color=discord.Color.green(), + timestamp=datetime.now() + ) + log_embed.add_field(name="Utilisateur", value=user.mention, inline=True) + log_embed.add_field(name="CatĂ©gorie", value=self.category.name, inline=True) + log_embed.add_field(name="Salon", value=ticket_channel.mention, inline=True) + await log_channel.send(embed=log_embed) + + await interaction.response.send_message(f"Ticket créé: {ticket_channel.mention}", ephemeral=True) + + +class TicketManagementView(discord.ui.View): + """Gestion du ticket (dans le salon du ticket)""" + + def __init__(self, bot, storage, config: GuildTicketConfig, ticket: TicketData): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.ticket = ticket + self.category = config.get_category(ticket.category_id) + + # Claim (if enabled and ticket is open) + if config.claim_enabled and self.category and self.category.allow_claims and ticket.status == TicketStatus.OPEN: + claim_btn = discord.ui.Button( + style=discord.ButtonStyle.primary, + label="RĂ©clamer", + custom_id="ticket_claim" + ) + claim_btn.callback = self._claim_callback + self.add_item(claim_btn) + + # Fermer + close_btn = discord.ui.Button( + style=discord.ButtonStyle.red, + label="Fermer", + custom_id="ticket_close" + ) + close_btn.callback = self._close_callback + self.add_item(close_btn) + + # Transcripts + transcript_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="Transcript", + custom_id="ticket_transcript" + ) + transcript_btn.callback = self._transcript_callback + self.add_item(transcript_btn) + + # Reopen (if closed) + if ticket.status == TicketStatus.CLOSED: + reopen_btn = discord.ui.Button( + style=discord.ButtonStyle.green, + label="Rouvrir", + custom_id="ticket_reopen" + ) + reopen_btn.callback = self._reopen_callback + self.add_item(reopen_btn) + + async def _close_callback(self, interaction: discord.Interaction): + """Ferme le ticket""" + modal = CloseTicketModal(self.bot, self.storage, self.config, self.ticket) + await interaction.response.send_modal(modal) + + async def _transcript_callback(self, interaction: discord.Interaction): + """GĂ©nĂšre le transcript""" + if not self._is_staff(interaction): + await interaction.response.send_message("RĂ©servĂ© au staff.", ephemeral=True) + return + + storage = get_storage() + ticket = storage.load_ticket(interaction.channel.id) + if not ticket: + await interaction.response.send_message("Ticket non trouvĂ©.", ephemeral=True) + return + # Collect messages messages = [] - async for msg in target.history(limit=1000, oldest_first=True): + async for msg in interaction.channel.history(limit=1000, oldest_first=True): if msg.author.bot: continue messages.append({ @@ -335,274 +694,883 @@ class Ticket(commands.Cog): "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) + "is_staff": self._is_staff_member(msg.author) }) - + # Generate transcript + from .utils.transcript import get_transcript_generator 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) + + if filepath and os.path.exists(filepath): + file = discord.File(filepath, filename=os.path.basename(filepath)) + await interaction.response.send_message("Transcript gĂ©nĂ©rĂ©:", file=file, ephemeral=True) else: - await ctx.send("❌ Erreur lors de la gĂ©nĂ©ration.", ephemeral=True) + await interaction.response.send_message("Erreur lors de la gĂ©nĂ©ration du transcript.", ephemeral=True) + + async def _claim_callback(self, interaction: discord.Interaction): + """RĂ©clame le ticket""" + if not self._is_staff(interaction): + await interaction.response.send_message("RĂ©servĂ© au staff.", ephemeral=True) + return + + if self.ticket.claimed_by: + await interaction.response.send_message("Ce ticket est dĂ©jĂ  rĂ©clamĂ©.", ephemeral=True) + return + + self.ticket.claimed_by = interaction.user.id + self.ticket.status = TicketStatus.CLAIMED + self.storage.save_ticket(self.ticket) + + # Update view (remove claim button) + for item in self.children: + if item.custom_id == "ticket_claim": + self.remove_item(item) + break + + # Force UI update + await interaction.response.edit_message(view=self) + + embed = TicketEmbedFormatter.ticket_claimed(self.ticket, interaction.user) + await interaction.channel.send(embed=embed) + + async def _reopen_callback(self, interaction: discord.Interaction): + """Rouvre le ticket""" + if not self._is_staff(interaction): + await interaction.response.send_message("RĂ©servĂ© au staff.", ephemeral=True) + return + + self.ticket.status = TicketStatus.OPEN + self.ticket.closed_at = None + self.ticket.claimed_by = None + self.storage.save_ticket(self.ticket) + + # Update channel + 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=True + ) + + await interaction.channel.edit( + name=interaction.channel.name.replace("closed-", ""), + overwrites=overwrites + ) + + # Update view (remove reopen button, add claim if enabled) + for item in self.children: + if item.custom_id == "ticket_reopen": + self.remove_item(item) + break + + if self.config.claim_enabled and self.category and self.category.allow_claims: + claim_btn = discord.ui.Button( + style=discord.ButtonStyle.primary, + label="RĂ©clamer", + custom_id="ticket_claim" + ) + claim_btn.callback = self._claim_callback + self.add_item(claim_btn) + + embed = TicketEmbedFormatter.ticket_reopened(self.ticket, interaction.user) + await interaction.channel.send(embed=embed) + await interaction.response.send_message("Ticket rouvert!", 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: + def _is_staff(self, interaction: discord.Interaction) -> bool: + 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 + return user.guild_permissions.administrator + + def _is_staff_member(self, member) -> bool: + 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 - - # --- 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 +class CloseTicketModal(discord.ui.Modal): + """Modal pour fermer un ticket""" + + 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.category = config.get_category(ticket.category_id) + + self.reason = discord.ui.TextInput( + label="Raison de fermeture", + placeholder="Optionnel...", + style=discord.TextStyle.paragraph, + required=False, + max_length=300 + ) + self.add_item(self.reason) + + # Survey fields if enabled + if config.survey_enabled and self.category and self.category.survey_enabled: + self.rating = discord.ui.TextInput( + label="Note (1-5 Ă©toiles)", + placeholder="1 Ă  5", + required=False, + max_length=1 + ) + self.add_item(self.rating) + + self.feedback = discord.ui.TextInput( + label="Commentaires", + placeholder="Vos commentaires sur le support...", + style=discord.TextStyle.paragraph, + required=False, + max_length=500 + ) + self.add_item(self.feedback) - @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) + async def on_submit(self, interaction: discord.Interaction): + # Check permissions + can_close = ( + interaction.user.id == self.ticket.user_id or + self._is_staff(interaction) + ) + + if not can_close: + await interaction.response.send_message("Vous ne pouvez pas fermer ce ticket.", ephemeral=True) + return + + # Handle survey if enabled + if hasattr(self, 'rating') and self.rating.value: + try: + rating = int(self.rating.value) + if 1 <= rating <= 5: + self.ticket.rating = rating + else: + await interaction.response.send_message("La note doit ĂȘtre entre 1 et 5.", ephemeral=True) + return + except ValueError: + await interaction.response.send_message("Note invalide.", ephemeral=True) + return + + if hasattr(self, 'feedback') and self.feedback.value: + self.ticket.feedback = self.feedback.value + + # Send ephemeral response to user first (required for modal interactions) + await interaction.response.send_message("✅ Fermeture du ticket en cours...", ephemeral=True) + + # Send confirmation message in the ticket channel + confirm_embed = discord.Embed( + title="⏳ Fermeture du Ticket", + description="Le ticket va ĂȘtre fermĂ© dans quelques secondes...\n\n" + "📄 GĂ©nĂ©ration du transcript en cours...\n" + "📝 Envoi aux logs...\n" + "đŸ—‘ïž Suppression du canal...", + color=discord.Color.orange() + ) + await interaction.channel.send(embed=confirm_embed) + + # Add small delay for user to see the confirmation + import asyncio + await asyncio.sleep(3) + + # Generate transcript before closing + transcript_file = None + try: + # 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(), + "is_staff": self._is_staff_member(msg.author) + }) + + # Generate transcript + from .utils.transcript import get_transcript_generator + transcript_gen = get_transcript_generator() + transcript_file = transcript_gen.save_transcript(self.ticket, messages, format="html") + except Exception as e: + print(f"Error generating transcript: {e}") + + # Close ticket + self.ticket.status = TicketStatus.CLOSED + self.ticket.closed_at = datetime.now() + self.storage.save_ticket(self.ticket) + + duration = self.ticket.duration_minutes + + # Send transcript to log channel if configured + if self.config.log_channel_id and transcript_file and os.path.exists(transcript_file): + try: + log_channel = interaction.guild.get_channel(self.config.log_channel_id) + if log_channel: + # Create log embed + log_embed = discord.Embed( + title="📄 Ticket FermĂ© - Transcript", + description=f"Ticket #{self.ticket.channel_id} fermĂ© par {interaction.user.mention}", + color=discord.Color.red(), + timestamp=datetime.now() + ) + + # Get ticket creator + ticket_creator = interaction.guild.get_member(self.ticket.user_id) + creator_mention = ticket_creator.mention if ticket_creator else f"Utilisateur {self.ticket.user_id}" + + log_embed.add_field(name="CrĂ©ateur", value=creator_mention, inline=True) + log_embed.add_field(name="CatĂ©gorie", value=self.category.name if self.category else "N/A", inline=True) + log_embed.add_field(name="DurĂ©e", value=f"{duration:.1f} min" if duration else "N/A", inline=True) + + if self.reason.value: + log_embed.add_field(name="Raison de fermeture", value=self.reason.value, inline=False) + + # Send transcript file + file = discord.File(transcript_file, filename=os.path.basename(transcript_file)) + await log_channel.send(embed=log_embed, file=file) + except Exception as e: + print(f"Error sending transcript to log channel: {e}") + + # Delete the ticket channel + try: + await interaction.channel.delete(reason=f"Ticket fermĂ© par {interaction.user}") + except Exception as e: + print(f"Error deleting channel: {e}") + # Fallback: just mark as closed if deletion fails + await interaction.followup.send("Erreur lors de la suppression du canal, mais le ticket a Ă©tĂ© fermĂ©.", ephemeral=True) - @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) - + def _is_staff(self, interaction: discord.Interaction) -> bool: + 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 + return user.guild_permissions.administrator + + +class TicketSetupView(discord.ui.View): + """Panel de configuration complet du systĂšme de tickets""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + + # Toggle enabled + toggle_btn = discord.ui.Button( + style=discord.ButtonStyle.green if config.enabled else discord.ButtonStyle.red, + label="🔄 Activer/DĂ©sactiver", + custom_id="setup_toggle" + ) + toggle_btn.callback = self._toggle_callback + self.add_item(toggle_btn) + + # Staff roles + roles_btn = discord.ui.Button( + style=discord.ButtonStyle.primary, + label="đŸ‘„ RĂŽles Staff", + custom_id="setup_roles" + ) + roles_btn.callback = self._roles_callback + self.add_item(roles_btn) + + # Config roles + config_roles_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="🔑 RĂŽles Config", + custom_id="setup_config_roles" + ) + config_roles_btn.callback = self._config_roles_callback + self.add_item(config_roles_btn) + + # Categories + cat_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="📂 CatĂ©gories", + custom_id="setup_categories" + ) + cat_btn.callback = self._categories_callback + self.add_item(cat_btn) + + # Log channel + log_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="📝 Canal de Logs", + custom_id="setup_log_channel" + ) + log_btn.callback = self._log_channel_callback + self.add_item(log_btn) + + # Advanced settings + advanced_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="⚙ ParamĂštres AvancĂ©s", + custom_id="setup_advanced" + ) + advanced_btn.callback = self._advanced_callback + self.add_item(advanced_btn) + + async def _toggle_callback(self, interaction: discord.Interaction): + self.config.enabled = not self.config.enabled + self.storage.save_config(interaction.guild_id, self.config) + + status = "activĂ©" if self.config.enabled else "dĂ©sactivĂ©" + embed = discord.Embed( + title="🔄 État du SystĂšme", + description=f"Le systĂšme de tickets a Ă©tĂ© **{status}**!", + color=discord.Color.green() if self.config.enabled else discord.Color.red() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # Update button + for item in self.children: + if item.custom_id == "setup_toggle": + item.style = discord.ButtonStyle.green if self.config.enabled else discord.ButtonStyle.red + await interaction.message.edit(view=self) + + async def _roles_callback(self, interaction: discord.Interaction): + view = StaffRolesSetupView(self.bot, self.storage, self.config, interaction.guild) + + embed = discord.Embed( + title="đŸ‘„ Configuration des RĂŽles Staff", + description="DĂ©finissez les rĂŽles qui peuvent gĂ©rer les tickets et accĂ©der aux fonctionnalitĂ©s avancĂ©es.\n\n" + "**Permissions des rĂŽles staff:**\n" + "‱ GĂ©rer les tickets (rĂ©clamer, fermer, etc.)\n" + "‱ AccĂ©der aux analytics et statistiques\n" + "‱ GĂ©nĂ©rer des transcripts\n" + "‱ Fermer les tickets des autres", + color=discord.Color.blue() + ) + 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 Staff Actuels", + value=", ".join(role_mentions) if role_mentions else "❌ Aucun rĂŽle configurĂ©", + inline=False + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + async def _categories_callback(self, interaction: discord.Interaction): + view = CategoriesSetupView(self.bot, self.storage, self.config) + embed = discord.Embed( - title="đŸ‘„ Gestion des RĂŽles Staff", - description="SĂ©lectionnez les rĂŽles qui auront accĂšs aux tickets:", + title="📂 Gestion des CatĂ©gories", + description="CrĂ©ez et gĂ©rez les catĂ©gories de tickets pour organiser vos demandes.", color=discord.Color.blue() ) + + if self.config.categories: + cat_list = [] + for cat in self.config.categories[:10]: # Limit to 10 for display + cat_list.append(f"{cat.emoji} {cat.name}") + embed.add_field( + name=f"CatĂ©gories ({len(self.config.categories)})", + value="\n".join(cat_list), + inline=False + ) + else: + embed.add_field( + name="Aucune catĂ©gorie", + value="CrĂ©ez votre premiĂšre catĂ©gorie pour commencer!", + inline=False + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + async def _config_roles_callback(self, interaction: discord.Interaction): + view = ConfigRolesSetupView(self.bot, self.storage, self.config, interaction.guild) + + embed = discord.Embed( + title="🔑 Configuration des RĂŽles Config", + description="DĂ©finissez les rĂŽles qui peuvent accĂ©der au panel de configuration du systĂšme de tickets.\n\n" + "**Permissions des rĂŽles config:**\n" + "‱ AccĂ©der Ă  la commande /ticketconfig\n" + "‱ Modifier tous les paramĂštres du systĂšme\n" + "‱ GĂ©rer les catĂ©gories et rĂŽles staff", + color=discord.Color.purple() + ) + + role_mentions = [] + for role_id in self.config.config_roles: + role = interaction.guild.get_role(int(role_id)) + if role: + role_mentions.append(role.mention) + embed.add_field( - name="RĂŽles actuels", + name="RĂŽles Config Actuels", + value=", ".join(role_mentions) if role_mentions else "❌ Aucun rĂŽle configurĂ©", + inline=False + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + async def _log_channel_callback(self, interaction: discord.Interaction): + modal = SetChannelModal(self.bot, self.storage, self.config, "log") + await interaction.response.send_modal(modal) + + async def _advanced_callback(self, interaction: discord.Interaction): + view = AdvancedSetupView(self.bot, self.storage, self.config) + + embed = discord.Embed( + title="⚙ ParamĂštres AvancĂ©s", + description="Configuration fine du systĂšme de tickets.", + color=discord.Color.purple() + ) + + embed.add_field( + name="ParamĂštres Actuels", + value=f"SystĂšme de rĂ©clamation: {'✅ ActivĂ©' if self.config.claim_enabled else '❌ DĂ©sactivĂ©'}\n" + f"Sondages: {'✅ ActivĂ©' if self.config.survey_enabled else '❌ DĂ©sactivĂ©'}\n" + f"Limite par utilisateur: {self.config.max_tickets_per_user}", + inline=False + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + +class StaffRolesSetupView(discord.ui.View): + """Configuration des rĂŽles staff""" + + def __init__(self, bot, storage, config: GuildTicketConfig, guild: discord.Guild): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.guild = guild + self._build_select() + + def _build_select(self): + """Build the role select menu""" + options = [] + for role in self.guild.roles: + if role.id != self.guild.id: # Exclude @everyone + options.append( + discord.SelectOption( + label=role.name, + value=str(role.id), + default=str(role.id) in self.config.staff_roles + ) + ) + + if options: + select = discord.ui.Select( + placeholder="SĂ©lectionnez les rĂŽles staff...", + options=options[:25], + min_values=0, + max_values=min(25, len(options)) + ) + select.callback = self._select_callback + self.add_item(select) + + async def _select_callback(self, interaction: discord.Interaction): + selected = list(interaction.data.get("values", [])) + self.config.staff_roles = selected + + self.storage.save_config(interaction.guild_id, self.config) + + role_mentions = [] + for role_id in selected: + role = interaction.guild.get_role(int(role_id)) + if role: + role_mentions.append(role.mention) + + embed = discord.Embed( + title="✅ RĂŽles Staff Mis Ă  Jour", + description=f"**{len(selected)}** rĂŽle(s) configurĂ©(s) comme staff.", + color=discord.Color.green() + ) + + embed.add_field( + name="RĂŽles Staff", 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_message(embed=embed, ephemeral=True) + + +class ConfigRolesSetupView(discord.ui.View): + """Configuration des rĂŽles config""" + + def __init__(self, bot, storage, config: GuildTicketConfig, guild: discord.Guild): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.guild = guild + self._build_select() + + def _build_select(self): + """Build the role select menu""" + options = [] + for role in self.guild.roles: + if role.id != self.guild.id: # Exclude @everyone + options.append( + discord.SelectOption( + label=role.name, + value=str(role.id), + default=str(role.id) in self.config.config_roles + ) + ) + + if options: + select = discord.ui.Select( + placeholder="SĂ©lectionnez les rĂŽles config...", + options=options[:25], + min_values=0, + max_values=min(25, len(options)) + ) + select.callback = self._select_callback + self.add_item(select) + + async def _select_callback(self, interaction: discord.Interaction): + selected = list(interaction.data.get("values", [])) + self.config.config_roles = selected + + self.storage.save_config(interaction.guild_id, self.config) + + role_mentions = [] + for role_id in selected: + role = interaction.guild.get_role(int(role_id)) + if role: + role_mentions.append(role.mention) + + embed = discord.Embed( + title="✅ RĂŽles Config Mis Ă  Jour", + description=f"**{len(selected)}** rĂŽle(s) configurĂ©(s) pour accĂ©der Ă  la configuration.", + color=discord.Color.green() + ) + + embed.add_field( + name="RĂŽles Config", + value=", ".join(role_mentions) if role_mentions else "Aucun", + inline=False + ) + + embed.add_field( + name="Permissions", + value="‱ AccĂ©der Ă  /ticketconfig\n‱ Modifier la configuration\n‱ GĂ©rer les catĂ©gories et rĂŽles", + inline=False + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class CategoriesSetupView(discord.ui.View): + """Gestion des catĂ©gories""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + + # Add category + add_btn = discord.ui.Button( + style=discord.ButtonStyle.green, + label="➕ Ajouter", + custom_id="cat_add" + ) + add_btn.callback = self._add_callback + self.add_item(add_btn) + + # Remove category (only if categories exist) + if config.categories: + remove_btn = discord.ui.Button( + style=discord.ButtonStyle.red, + label="➖ Supprimer", + custom_id="cat_remove" + ) + remove_btn.callback = self._remove_callback + self.add_item(remove_btn) + + async def _add_callback(self, interaction: discord.Interaction): + modal = AddCategoryModal(self.bot, 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): + + async def _remove_callback(self, interaction: discord.Interaction): + if not self.config.categories: + await interaction.response.send_message("Aucune catĂ©gorie Ă  supprimer.", ephemeral=True) + return + + # Create select with current categories + options = [] + for cat in self.config.categories[:25]: # Discord limit + options.append( + discord.SelectOption( + label=cat.name, + value=cat.id, + emoji=cat.emoji + ) + ) + + select = discord.ui.Select( + placeholder="SĂ©lectionnez une catĂ©gorie Ă  supprimer...", + options=options + ) + select.callback = self._remove_select_callback + + view = discord.ui.View() + view.add_item(select) + + embed = discord.Embed( + title="Supprimer une CatĂ©gorie", + description="SĂ©lectionnez la catĂ©gorie Ă  supprimer:", + color=discord.Color.red() + ) + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + async def _remove_select_callback(self, interaction: discord.Interaction): + cat_id = interaction.data["values"][0] + + # Remove category + self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id] + self.storage.save_config(interaction.guild_id, self.config) + + await interaction.response.send_message("✅ CatĂ©gorie supprimĂ©e!", ephemeral=True) + + +class AdvancedSetupView(discord.ui.View): + """ParamĂštres avancĂ©s""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + + # Toggle claim system + claim_btn = discord.ui.Button( + style=discord.ButtonStyle.green if config.claim_enabled else discord.ButtonStyle.red, + label=f"{'✅' if config.claim_enabled else '❌'} SystĂšme de RĂ©clamation", + custom_id="advanced_claim" + ) + claim_btn.callback = self._claim_callback + self.add_item(claim_btn) + + # Toggle survey system + survey_btn = discord.ui.Button( + style=discord.ButtonStyle.green if config.survey_enabled else discord.ButtonStyle.red, + label=f"{'✅' if config.survey_enabled else '❌'} Sondages", + custom_id="advanced_survey" + ) + survey_btn.callback = self._survey_callback + self.add_item(survey_btn) + + # Max tickets per user + max_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label=f"📊 Limite: {config.max_tickets_per_user}", + custom_id="advanced_max" + ) + max_btn.callback = self._max_callback + self.add_item(max_btn) + + async def _claim_callback(self, interaction: discord.Interaction): + self.config.claim_enabled = not self.config.claim_enabled + self.storage.save_config(interaction.guild_id, self.config) + + status = "activĂ©" if self.config.claim_enabled else "dĂ©sactivĂ©" + embed = discord.Embed( + title="🔄 SystĂšme de RĂ©clamation", + description=f"Le systĂšme de rĂ©clamation a Ă©tĂ© **{status}**!", + color=discord.Color.green() if self.config.claim_enabled else discord.Color.red() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # Update button + for item in self.children: + if item.custom_id == "advanced_claim": + item.style = discord.ButtonStyle.green if self.config.claim_enabled else discord.ButtonStyle.red + item.label = f"{'✅' if self.config.claim_enabled else '❌'} SystĂšme de RĂ©clamation" + await interaction.message.edit(view=self) + + async def _survey_callback(self, interaction: discord.Interaction): + self.config.survey_enabled = not self.config.survey_enabled + self.storage.save_config(interaction.guild_id, self.config) + + status = "activĂ©" if self.config.survey_enabled else "dĂ©sactivĂ©" + embed = discord.Embed( + title="🔄 SystĂšme de Sondages", + description=f"Le systĂšme de sondages a Ă©tĂ© **{status}**!", + color=discord.Color.green() if self.config.survey_enabled else discord.Color.red() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # Update button + for item in self.children: + if item.custom_id == "advanced_survey": + item.style = discord.ButtonStyle.green if self.config.survey_enabled else discord.ButtonStyle.red + item.label = f"{'✅' if self.config.survey_enabled else '❌'} Sondages" + await interaction.message.edit(view=self) + + async def _max_callback(self, interaction: discord.Interaction): + modal = MaxTicketsModal(self.bot, self.storage, self.config) + await interaction.response.send_modal(modal) + + +class MaxTicketsModal(discord.ui.Modal): + """Modal pour dĂ©finir la limite de tickets par utilisateur""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(title="Limite de Tickets par Utilisateur") + self.bot = bot + self.storage = storage + self.config = config + + self.max_tickets = discord.ui.TextInput( + label="Nombre maximum de tickets ouverts par utilisateur", + placeholder="5", + default=str(config.max_tickets_per_user), + required=True, + max_length=2 + ) + self.add_item(self.max_tickets) + + async def on_submit(self, interaction: discord.Interaction): + try: + max_tickets = int(self.max_tickets.value) + if max_tickets < 1 or max_tickets > 50: + await interaction.response.send_message("La limite doit ĂȘtre entre 1 et 50.", ephemeral=True) + return + + self.config.max_tickets_per_user = max_tickets + self.storage.save_config(interaction.guild_id, self.config) + + embed = discord.Embed( + title="✅ Limite Mise Ă  Jour", + description=f"La limite de tickets par utilisateur est maintenant de **{max_tickets}**.", + color=discord.Color.green() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + except ValueError: + await interaction.response.send_message("Veuillez entrer un nombre valide.", ephemeral=True) + + +class ConfigPanelView(discord.ui.View): + """Panel de configuration""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + + # Toggle enabled + toggle_btn = discord.ui.Button( + style=discord.ButtonStyle.green if config.enabled else discord.ButtonStyle.red, + label="Activer/DĂ©sactiver", + custom_id="config_toggle" + ) + toggle_btn.callback = self._toggle_callback + self.add_item(toggle_btn) + + # Ajouter catĂ©gorie + add_cat_btn = discord.ui.Button( + style=discord.ButtonStyle.blurple, + label="Ajouter CatĂ©gorie", + custom_id="config_add_cat" + ) + add_cat_btn.callback = self._add_cat_callback + self.add_item(add_cat_btn) + + # Staff roles + roles_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="RĂŽles Staff", + custom_id="config_roles" + ) + roles_btn.callback = self._roles_callback + self.add_item(roles_btn) + + # Log channel + log_btn = discord.ui.Button( + style=discord.ButtonStyle.secondary, + label="Canal de Logs", + custom_id="config_log_channel" + ) + log_btn.callback = self._log_channel_callback + self.add_item(log_btn) + + async def _toggle_callback(self, interaction: discord.Interaction): 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) + self.storage.save_config(interaction.guild_id, self.config) + + status = "activĂ©" if self.config.enabled else "dĂ©sactivĂ©" + await interaction.response.send_message(f"SystĂšme {status}!", ephemeral=True) + + # Update button + for item in self.children: + if item.custom_id == "config_toggle": + item.style = discord.ButtonStyle.green if self.config.enabled else discord.ButtonStyle.red + await interaction.message.edit(view=self) + + async def _add_cat_callback(self, interaction: discord.Interaction): + modal = AddCategoryModal(self.bot, self.storage, self.config) + await interaction.response.send_modal(modal) + + async def _roles_callback(self, interaction: discord.Interaction): + view = StaffRolesView(self.bot, self.storage, self.config, interaction.guild) + + embed = discord.Embed( + title="RĂŽles Staff", + description="SĂ©lectionnez les rĂŽles qui peuvent gĂ©rer les tickets:", + color=discord.Color.blue() + ) + + 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="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 _log_channel_callback(self, interaction: discord.Interaction): + modal = SetChannelModal(self.bot, self.storage, self.config, "log") + await interaction.response.send_modal(modal) class AddCategoryModal(discord.ui.Modal): - """Modal for adding a category""" + """Modal pour ajouter une catĂ©gorie""" def __init__(self, bot, storage, config: GuildTicketConfig): - super().__init__(title="➕ Nouvelle CatĂ©gorie") + 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.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 + label="Description", placeholder="Description...", 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.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): @@ -618,44 +1586,364 @@ class AddCategoryModal(discord.ui.Modal): ) self.config.categories.append(category) + self.storage.save_config(interaction.guild_id, self.config) - 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 - ) + await interaction.response.send_message(f"CatĂ©gorie **{self.name.value}** créée!", ephemeral=True) -class LogChannelModal(discord.ui.Modal): - """Modal for setting log channel""" +class SetChannelModal(discord.ui.Modal): + """Modal pour dĂ©finir le canal de logs""" - def __init__(self, storage, config: GuildTicketConfig): - super().__init__(title="📊 Canal Logs") + def __init__(self, bot, storage, config: GuildTicketConfig, channel_type: str = "log"): + super().__init__(title="DĂ©finir le Canal de Logs") + self.bot = bot + self.storage = storage + self.config = config + self.channel_type = channel_type + + self.channel = discord.ui.TextInput( + label="Canal de logs", + placeholder="Mentionnez le canal ou entrez l'ID (ex: #general ou 123456789)", + required=True, + max_length=100 + ) + self.add_item(self.channel) + + async def on_submit(self, interaction: discord.Interaction): + channel_input = self.channel.value.strip() + + # Try to extract channel ID from mention or ID + channel_id = None + + # Check for mention format (#channel) + if channel_input.startswith('<#') and channel_input.endswith('>'): + try: + channel_id = int(channel_input[2:-1]) + except ValueError: + pass + # Check for raw ID + else: + try: + channel_id = int(channel_input.replace('<', '').replace('>', '')) + except ValueError: + pass + + if not channel_id: + await interaction.response.send_message( + "❌ Format invalide. Utilisez une mention (#channel) ou un ID de canal.", + ephemeral=True + ) + return + + # Verify channel exists + channel = interaction.guild.get_channel(channel_id) + if not channel: + await interaction.response.send_message( + "❌ Ce canal n'existe pas sur ce serveur.", + ephemeral=True + ) + return + + # Update config + self.config.log_channel_id = channel_id + self.storage.save_config(interaction.guild_id, self.config) + + embed = discord.Embed( + title="✅ Canal de Logs Mis Ă  Jour", + description=f"Le canal de logs a Ă©tĂ© dĂ©fini sur {channel.mention}.", + color=discord.Color.green() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class StaffRolesModal(discord.ui.Modal): + """Modal pour ajouter/supprimer les rĂŽles staff""" + + def __init__(self, bot, storage, config: GuildTicketConfig): + super().__init__(title="GĂ©rer les RĂŽles Staff") + self.bot = bot self.storage = storage self.config = config - self.channel_id = discord.ui.TextInput( - label="ID du Canal", - placeholder="Ex: 123456789012345678", - required=True + # Champ pour entrer les rĂŽles (mention ou ID) + self.roles = discord.ui.TextInput( + label="RĂŽles Staff (mention ou ID)", + placeholder="@Staff @ModĂ©rateurs ou 123456789 987654321", + required=True, + max_length=500 ) - self.add_item(self.channel_id) + self.add_item(self.roles) 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) + role_input = self.roles.value.strip() + role_ids = [] + + # Parse role mentions (@role) or IDs + import re + # Match <@&ROLE_ID> mentions + mention_matches = re.findall(r'<@&(\d+)>', role_input) + # Match raw numbers (IDs) + id_matches = re.findall(r'\b(\d{17,20})\b', role_input) + + all_ids = set(mention_matches + id_matches) + + if not all_ids: + await interaction.response.send_message( + "❌ Format invalide. Utilisez les mentions (@RĂŽle) ou les IDs.", + ephemeral=True + ) + return + + # Validate roles exist + valid_roles = [] + for role_id in all_ids: + role = interaction.guild.get_role(int(role_id)) + if role: + valid_roles.append(role) + else: + await interaction.response.send_message( + f"❌ Le rĂŽle {role_id} n'existe pas sur ce serveur.", + ephemeral=True + ) + return + + # Update config + self.config.staff_roles = list(all_ids) + self.storage.save_config(interaction.guild_id, self.config) + + # Build success embed + embed = discord.Embed( + title="✅ RĂŽles Staff Mis Ă  Jour", + description=f"**{len(valid_roles)}** rĂŽle(s) configurĂ©(s) comme staff.", + color=discord.Color.green() + ) + + role_mentions = [role.mention for role in valid_roles] + embed.add_field( + name="RĂŽles Staff", + value=", ".join(role_mentions) if role_mentions else "Aucun", + inline=False + ) + + embed.add_field( + name="Permissions", + value="‱ GĂ©rer les tickets\n‱ AccĂ©der aux analytics\n‱ GĂ©nĂ©rer des transcripts\n‱ Fermer les tickets des autres", + inline=False + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + except Exception as e: + await interaction.response.send_message(f"Erreur lors de la configuration des rĂŽles: {e}", ephemeral=True) + + async def on_error(self, interaction: discord.Interaction, error: Exception): + """Handle errors in the modal""" + await interaction.response.send_message("Une erreur inattendue s'est produite lors de la configuration des rĂŽles.", ephemeral=True) + + +class StaffRolesView(discord.ui.View): + """SĂ©lection des rĂŽles staff (alternative avec select menu)""" + + def __init__(self, bot, storage, config: GuildTicketConfig, guild: discord.Guild): + super().__init__(timeout=None) + self.bot = bot + self.storage = storage + self.config = config + self.guild = guild + + # Le select sera populated dans le callback + self._build_select(guild) + + def _build_select(self, guild: discord.Guild): + if not guild: + return + + options = [] + for role in guild.roles: + if role.id != guild.id: # Exclude @everyone + options.append( + discord.SelectOption( + label=role.name, + value=str(role.id), + default=str(role.id) in self.config.staff_roles + ) + ) + + if options: + select = discord.ui.Select( + placeholder="SĂ©lectionnez les rĂŽles staff...", + options=options[:25], + min_values=0, + max_values=min(25, len(options)) + ) + select.callback = self._select_callback + self.add_item(select) + + async def _select_callback(self, interaction: discord.Interaction): + selected = list(interaction.data.get("values", [])) + self.config.staff_roles = selected + + self.storage.save_config(interaction.guild_id, self.config) + + role_mentions = [] + for role_id in selected: + role = interaction.guild.get_role(int(role_id)) + if role: + role_mentions.append(role.mention) + + embed = discord.Embed( + title="✅ RĂŽles Staff Mis Ă  Jour", + description=f"**{len(selected)}** rĂŽle(s) configurĂ©(s).", + color=discord.Color.green() + ) + embed.add_field(name="RĂŽles", value=", ".join(role_mentions) if role_mentions else "Aucun", inline=False) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class TicketCommands(commands.Cog): + """Cog principal des tickets""" + + def __init__(self, bot): + self.bot = bot + self.storage = get_storage() + + def cog_unload(self): + pass + + async def cog_load(self): + await self._register_all_views() + + async def _register_all_views(self): + """Enregistre les vues pour tous les serveurs""" + try: + for guild_id in self.storage.get_all_guild_ids(): + try: + config = self.storage.load_config(guild_id) + if config and config.enabled: + view = TicketPanelView(self.bot, config, self.storage) + self.bot.add_view(view) + + # Register management views for existing tickets + tickets = self.storage.get_guild_tickets(guild_id) + for ticket in tickets: + if ticket.is_open: + mgmt_view = TicketManagementView(self.bot, self.storage, config, ticket) + self.bot.add_view(mgmt_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}") + + # === COMMANDES === + + @commands.hybrid_command(name="ticket", aliases=["tickets"]) + async def ticket_main(self, ctx): + """Ouvre le panel des tickets""" + await ctx.defer(ephemeral=True) + + config = self.storage.load_config(ctx.guild.id) + + embed = discord.Embed( + title="SystĂšme de Tickets", + description="GĂ©rez vos tickets simplement", + color=discord.Color.blurple() + ) + + if not config.enabled: + embed.add_field(name="État", value="Le systĂšme est actuellement **dĂ©sactivĂ©**.", inline=False) + else: + embed.add_field(name="CatĂ©gories", value=str(len(config.categories)), inline=True) + embed.add_field(name="Total tickets", value=str(len(self.storage.get_guild_tickets(ctx.guild.id))), inline=True) + + view = TicketPanelView(self.bot, config, self.storage) + await ctx.send(embed=embed, view=view, ephemeral=True) + + @commands.hybrid_command(name="ticketpanel", aliases=["panneau"]) + @commands.has_permissions(administrator=True) + async def ticket_panel(self, ctx, channel: discord.TextChannel = None): + """Envoie le panel de crĂ©ation de tickets dans un canal""" + await ctx.defer(ephemeral=True) + + config = self.storage.load_config(ctx.guild.id) + + if not config.enabled: + await ctx.send("Le systĂšme est dĂ©sactivĂ©.", ephemeral=True) + return + + target = channel or ctx.channel + + embed = discord.Embed( + title="Besoin d'aide?", + description="Cliquez sur le bouton ci-dessous pour crĂ©er un ticket.\n\nNotre Ă©quipe vous rĂ©pondra dĂšs que possible!", + color=discord.Color.blurple() + ) + embed.add_field(name="CatĂ©gories disponibles", value=str(len(config.categories)), inline=False) + + view = TicketPanelView(self.bot, config, self.storage) + await target.send(embed=embed, view=view) + + await ctx.send(f"Panel envoyĂ© dans {target.mention}", ephemeral=True) + + @commands.hybrid_command(name="ticketconfig", aliases=["ticketsetup", "configticket"]) + async def ticket_config(self, ctx): + """Ouvre le panel de configuration complet du systĂšme de tickets""" + await ctx.defer(ephemeral=True) + + config = self.storage.load_config(ctx.guild.id) + + # VĂ©rifier si l'utilisateur peut accĂ©der Ă  la configuration + can_access = ctx.author.guild_permissions.administrator + + if not can_access: + # VĂ©rifier les rĂŽles staff + for role_id in config.staff_roles: + role = ctx.guild.get_role(int(role_id)) + if role and role in ctx.author.roles: + can_access = True + break + + if not can_access: + await ctx.send("❌ Cette commande est rĂ©servĂ©e aux administrateurs ou aux rĂŽles configurĂ©s.", ephemeral=True) + return + + embed = discord.Embed( + title="⚙ Configuration du SystĂšme de Tickets", + description="Configurez complĂštement votre systĂšme de tickets depuis cette interface.\n\n" + "**Options disponibles:**\n" + "‱ Activer/DĂ©sactiver le systĂšme\n" + "‱ Configurer les rĂŽles staff\n" + "‱ GĂ©rer les catĂ©gories\n" + "‱ DĂ©finir le canal de logs\n" + "‱ Et bien plus...", + color=discord.Color.blue() + ) + + embed.add_field( + name="État actuel", + value=f"SystĂšme: {'✅ ActivĂ©' if config.enabled else '❌ DĂ©sactivĂ©'}\n" + f"CatĂ©gories: {len(config.categories)}\n" + f"RĂŽles staff: {len(config.staff_roles)}", + inline=True + ) + + embed.add_field( + name="Configuration requise", + value="‱ **RĂŽles Staff**: DĂ©finissez qui peut gĂ©rer les tickets\n" + "‱ **CatĂ©gories**: CrĂ©ez des catĂ©gories pour organiser les tickets\n" + "‱ **Canal de Logs**: OĂč envoyer les notifications", + inline=True + ) + + embed.set_footer(text="Utilisez les boutons ci-dessous pour configurer le systĂšme") + + view = TicketSetupView(self.bot, self.storage, config) + await ctx.send(embed=embed, view=view, ephemeral=True) async def setup(bot): - """Setup function for the cog""" - await bot.add_cog(Ticket(bot)) + """Setup function""" + await bot.add_cog(TicketCommands(bot)) diff --git a/commandes/ticket/analytics.py b/commandes/ticket/analytics.py new file mode 100644 index 0000000..7f70d2d --- /dev/null +++ b/commandes/ticket/analytics.py @@ -0,0 +1,497 @@ +""" +Staff Analytics Command for Ticket System +========================================== +A modular command for staff to view ticket analytics with beautiful graphics. +Shows response times, ratings, comments, and other key metrics. +""" + +import os +import sys +import io +from datetime import datetime, timedelta +from collections import defaultdict +from typing import List, Dict, Any, Optional +from PIL import Image, ImageDraw, ImageFont + +# 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 +from discord import app_commands + +from .data.storage import get_storage +from .data.models import TicketData, TicketStatus, GuildTicketConfig + + +class TicketAnalytics(commands.Cog): + """Analytics command for staff to view ticket statistics and ratings""" + + def __init__(self, bot): + self.bot = bot + self.storage = get_storage() + + def _is_staff(self, interaction: discord.Interaction) -> bool: + """Check if user is staff""" + guild = interaction.guild + user = interaction.user + 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 user.roles: + return True + + return user.guild_permissions.administrator + + def _calculate_comprehensive_stats(self, guild_id: int) -> Dict[str, Any]: + """Calculate comprehensive statistics for analytics""" + tickets = self.storage.get_guild_tickets(guild_id) + + stats = { + 'total_tickets': len(tickets), + 'open_tickets': 0, + 'closed_tickets': 0, + 'claimed_tickets': 0, + 'ratings': [], + 'response_times': [], + 'feedbacks': [], + 'tickets_by_day': defaultdict(int), + 'tickets_by_category': defaultdict(int), + 'tickets_by_priority': defaultdict(int), + 'average_rating': 0, + 'average_response_time': 0, + 'total_response_time': 0, + 'response_time_count': 0 + } + + for ticket in tickets: + # Basic counts + 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 + + # Ratings and feedback + if ticket.rating: + stats['ratings'].append(ticket.rating) + if ticket.feedback: + stats['feedbacks'].append({ + 'rating': ticket.rating, + 'feedback': ticket.feedback, + 'user_id': ticket.user_id, + 'ticket_id': ticket.channel_id + }) + + # Response time calculation + if ticket.claimed_by and ticket.created_at: + claimed_time = None + for msg in ticket.messages: + if msg.author_id == ticket.claimed_by and msg.is_staff: + claimed_time = msg.timestamp + break + + if claimed_time: + response_time = (claimed_time - ticket.created_at).total_seconds() / 60 # minutes + stats['response_times'].append(response_time) + stats['total_response_time'] += response_time + stats['response_time_count'] += 1 + + # Daily stats + day_key = ticket.created_at.strftime('%Y-%m-%d') + stats['tickets_by_day'][day_key] += 1 + + # Category stats + stats['tickets_by_category'][ticket.category_id] += 1 + + # Priority stats + stats['tickets_by_priority'][ticket.priority.value] += 1 + + # Calculate averages + if stats['ratings']: + stats['average_rating'] = sum(stats['ratings']) / len(stats['ratings']) + + if stats['response_time_count'] > 0: + stats['average_response_time'] = stats['total_response_time'] / stats['response_time_count'] + + return stats + + def _create_rating_chart(self, ratings: List[int]) -> io.BytesIO: + """Create a beautiful rating distribution chart using Pillow""" + # Image dimensions + width, height = 800, 500 + img = Image.new('RGB', (width, height), '#2F3136') + draw = ImageDraw.Draw(img) + + # Try to load a font, fallback to default if not available + try: + font_title = ImageFont.truetype("arial.ttf", 24) + font_label = ImageFont.truetype("arial.ttf", 16) + font_value = ImageFont.truetype("arial.ttf", 14) + except: + font_title = ImageFont.load_default() + font_label = ImageFont.load_default() + font_value = ImageFont.load_default() + + # Count ratings + rating_counts = defaultdict(int) + for rating in ratings: + rating_counts[rating] += 1 + + values = [rating_counts[i] for i in range(1, 6)] + max_value = max(values) if values else 1 + + # Chart dimensions + chart_left = 100 + chart_right = width - 50 + chart_top = 80 + chart_bottom = height - 100 + chart_width = chart_right - chart_left + chart_height = chart_bottom - chart_top + + # Draw title + title = "Distribution des Notes Clients" + draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm') + + # Draw bars + bar_width = chart_width // 6 + for i, value in enumerate(values): + if max_value > 0: + bar_height = int((value / max_value) * chart_height) + else: + bar_height = 0 + + x1 = chart_left + i * bar_width + 10 + y1 = chart_bottom - bar_height + x2 = x1 + bar_width - 20 + y2 = chart_bottom + + # Draw bar + draw.rectangle([x1, y1, x2, y2], fill='#5865F2', outline='#FFFFFF', width=2) + + # Draw value on top of bar + if value > 0: + draw.text((x1 + (x2-x1)//2, y1 - 10), str(value), fill='white', font=font_value, anchor='mm') + + # Draw x-axis labels + labels = ['1 ⭐', '2 ⭐', '3 ⭐', '4 ⭐', '5 ⭐'] + for i, label in enumerate(labels): + x = chart_left + i * bar_width + bar_width//2 + draw.text((x, chart_bottom + 20), label, fill='white', font=font_label, anchor='mm') + + # Draw y-axis label + draw.text((30, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm') + + # Draw grid lines + for i in range(0, 6): + y = chart_bottom - (i * chart_height // 5) + draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1) + + # Save to buffer + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + def _create_response_time_chart(self, response_times: List[float]) -> io.BytesIO: + """Create response time distribution chart using Pillow""" + # Image dimensions + width, height = 800, 500 + img = Image.new('RGB', (width, height), '#2F3136') + draw = ImageDraw.Draw(img) + + # Try to load a font, fallback to default if not available + try: + font_title = ImageFont.truetype("arial.ttf", 20) + font_label = ImageFont.truetype("arial.ttf", 14) + font_value = ImageFont.truetype("arial.ttf", 12) + except: + font_title = ImageFont.load_default() + font_label = ImageFont.load_default() + font_value = ImageFont.load_default() + + # Create histogram bins + bins = [0, 5, 15, 30, 60, 120, 240, float('inf')] + labels = ['< 5min', '5-15min', '15-30min', '30-60min', '1-2h', '2-4h', '> 4h'] + + hist_values = [] + for i in range(len(bins) - 1): + count = sum(1 for rt in response_times if bins[i] <= rt < bins[i+1]) + hist_values.append(count) + + # Last bin for > 4h + hist_values.append(sum(1 for rt in response_times if rt >= bins[-2])) + + max_value = max(hist_values) if hist_values else 1 + + # Chart dimensions + chart_left = 120 + chart_right = width - 50 + chart_top = 80 + chart_bottom = height - 120 + chart_width = chart_right - chart_left + chart_height = chart_bottom - chart_top + + # Draw title + title = "Temps de RĂ©ponse Moyen" + draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm') + + # Draw bars + bar_width = chart_width // len(labels) + for i, value in enumerate(hist_values): + if max_value > 0: + bar_height = int((value / max_value) * chart_height) + else: + bar_height = 0 + + x1 = chart_left + i * bar_width + 5 + y1 = chart_bottom - bar_height + x2 = x1 + bar_width - 10 + y2 = chart_bottom + + # Draw bar + draw.rectangle([x1, y1, x2, y2], fill='#57F287', outline='#FFFFFF', width=2) + + # Draw value on top of bar + if value > 0: + draw.text((x1 + (x2-x1)//2, y1 - 10), str(value), fill='white', font=font_value, anchor='mm') + + # Draw x-axis labels + for i, label in enumerate(labels): + x = chart_left + i * bar_width + bar_width//2 + # Rotate text for better fit + draw.text((x, chart_bottom + 30), label, fill='white', font=font_label, anchor='mm') + + # Draw y-axis label + draw.text((40, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm') + + # Draw grid lines + for i in range(0, 6): + y = chart_bottom - (i * chart_height // 5) + draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1) + + # Save to buffer + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + def _create_trend_chart(self, tickets_by_day: Dict[str, int]) -> io.BytesIO: + """Create ticket trend chart over time using Pillow""" + # Image dimensions + width, height = 900, 500 + img = Image.new('RGB', (width, height), '#2F3136') + draw = ImageDraw.Draw(img) + + # Try to load a font, fallback to default if not available + try: + font_title = ImageFont.truetype("arial.ttf", 20) + font_label = ImageFont.truetype("arial.ttf", 14) + font_value = ImageFont.truetype("arial.ttf", 12) + except: + font_title = ImageFont.load_default() + font_label = ImageFont.load_default() + font_value = ImageFont.load_default() + + # Sort dates + sorted_dates = sorted(tickets_by_day.keys()) + values = [tickets_by_day[date] for date in sorted_dates] + + if not values: + # Return empty chart if no data + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + max_value = max(values) if values else 1 + + # Chart dimensions + chart_left = 100 + chart_right = width - 50 + chart_top = 80 + chart_bottom = height - 100 + chart_width = chart_right - chart_left + chart_height = chart_bottom - chart_top + + # Draw title + title = "Évolution des Tickets sur les Derniers Jours" + draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm') + + # Calculate points for line chart + points = [] + for i, (date_str, value) in enumerate(zip(sorted_dates, values)): + x = chart_left + (i * chart_width) // max(1, len(values) - 1) + if max_value > 0: + y = chart_bottom - int((value / max_value) * chart_height) + else: + y = chart_bottom + points.append((x, y)) + + # Draw line + if len(points) > 1: + draw.line(points, fill='#FEE75C', width=3, joint='curve') + + # Draw points and values + for i, ((x, y), value, date_str) in enumerate(zip(points, values, sorted_dates)): + # Draw point + draw.ellipse([x-4, y-4, x+4, y+4], fill='#FEE75C', outline='#FFFFFF', width=2) + + # Draw value above point + draw.text((x, y-15), str(value), fill='white', font=font_value, anchor='mm') + + # Draw date label (every few points to avoid crowding) + if i % max(1, len(points)//7) == 0 or i == len(points)-1: + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + date_label = date_obj.strftime('%d/%m') + draw.text((x, chart_bottom + 20), date_label, fill='white', font=font_label, anchor='mm') + + # Draw y-axis label + draw.text((40, height//2), 'Nombre de Tickets', fill='white', font=font_label, anchor='mm') + + # Draw grid lines + for i in range(0, 6): + y = chart_bottom - (i * chart_height // 5) + draw.line([chart_left, y, chart_right, y], fill='#FFFFFF', width=1) + + # Save to buffer + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + @app_commands.command(name="ticket_analytics", description="Affiche les statistiques dĂ©taillĂ©es des tickets (Staff uniquement)") + @app_commands.describe(period="PĂ©riode d'analyse (7d, 30d, 90d, all)") + async def ticket_analytics(self, interaction: discord.Interaction, period: str = "30d"): + """Display comprehensive ticket analytics for staff""" + await interaction.response.defer(ephemeral=True) + + # Check permissions + if not self._is_staff(interaction): + await interaction.followup.send("❌ Cette commande est rĂ©servĂ©e au staff.", ephemeral=True) + return + + # Parse period + days = 30 + if period == "7d": + days = 7 + elif period == "30d": + days = 30 + elif period == "90d": + days = 90 + elif period == "all": + days = None + + # Calculate stats + stats = self._calculate_comprehensive_stats(interaction.guild_id) + + # Filter by period if specified + if days: + cutoff_date = datetime.now() - timedelta(days=days) + tickets = self.storage.get_guild_tickets(interaction.guild_id) + filtered_tickets = [t for t in tickets if t.created_at >= cutoff_date] + + # Recalculate stats for filtered period + stats = self._calculate_comprehensive_stats(interaction.guild_id) + # Filter the data + stats['tickets_by_day'] = {k: v for k, v in stats['tickets_by_day'].items() + if datetime.strptime(k, '%Y-%m-%d') >= cutoff_date} + stats['ratings'] = [r for r in stats['ratings'] if any( + t.created_at >= cutoff_date and t.rating == r for t in filtered_tickets)] + stats['response_times'] = [rt for rt in stats['response_times'] if any( + t.created_at >= cutoff_date for t in filtered_tickets if hasattr(t, 'response_time') and t.response_time == rt)] + + # Create main embed + embed = discord.Embed( + title="📊 Analytics des Tickets", + description=f"Statistiques pour la pĂ©riode: **{period}**", + color=discord.Color.blue(), + timestamp=datetime.now() + ) + + # Basic stats + embed.add_field( + name="📈 Statistiques GĂ©nĂ©rales", + value=f"**Total:** {stats['total_tickets']}\n" + f"**Ouverts:** {stats['open_tickets']}\n" + f"**FermĂ©s:** {stats['closed_tickets']}\n" + f"**RĂ©clamĂ©s:** {stats['claimed_tickets']}", + inline=True + ) + + # Performance metrics + avg_rating = f"{stats['average_rating']:.1f}⭐" if stats['ratings'] else "N/A" + avg_response = f"{stats['average_response_time']:.1f}min" if stats['response_times'] else "N/A" + + embed.add_field( + name="⚡ Performance", + value=f"**Note moyenne:** {avg_rating}\n" + f"**Temps rĂ©ponse moyen:** {avg_response}\n" + f"**Feedbacks:** {len(stats['feedbacks'])}", + inline=True + ) + + # Send main embed + await interaction.followup.send(embed=embed, ephemeral=True) + + # Create and send charts + try: + # Rating chart + if stats['ratings']: + rating_chart = self._create_rating_chart(stats['ratings']) + await interaction.followup.send( + "📊 **Distribution des Notes Clients**", + file=discord.File(rating_chart, 'rating_distribution.png'), + ephemeral=True + ) + + # Response time chart + if stats['response_times']: + response_chart = self._create_response_time_chart(stats['response_times']) + await interaction.followup.send( + "⏱ **Temps de RĂ©ponse**", + file=discord.File(response_chart, 'response_times.png'), + ephemeral=True + ) + + # Trend chart + if stats['tickets_by_day']: + trend_chart = self._create_trend_chart(stats['tickets_by_day']) + await interaction.followup.send( + "📈 **Évolution des Tickets**", + file=discord.File(trend_chart, 'ticket_trends.png'), + ephemeral=True + ) + + except Exception as e: + await interaction.followup.send(f"❌ Erreur lors de la gĂ©nĂ©ration des graphiques: {e}", ephemeral=True) + + # Send feedback summary if available + if stats['feedbacks']: + feedback_embed = discord.Embed( + title="💬 Commentaires Clients", + description=f"Derniers commentaires ({min(5, len(stats['feedbacks']))} affichĂ©s)", + color=discord.Color.green() + ) + + # Sort by rating (lowest first to show areas for improvement) + sorted_feedbacks = sorted(stats['feedbacks'], key=lambda x: x['rating'] or 0) + + for i, fb in enumerate(sorted_feedbacks[:5]): + rating_text = f"{fb['rating']}⭐" if fb['rating'] else "N/A" + feedback_text = fb['feedback'][:200] + "..." if len(fb['feedback']) > 200 else fb['feedback'] + feedback_embed.add_field( + name=f"Ticket #{fb['ticket_id']} - {rating_text}", + value=feedback_text, + inline=False + ) + + await interaction.followup.send(embed=feedback_embed, ephemeral=True) + + +async def setup(bot): + """Setup function for the analytics cog""" + await bot.add_cog(TicketAnalytics(bot)) diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index 605551f..4bebb22 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -273,7 +273,8 @@ class GuildTicketConfig: default_category: Default category ID log_channel_id: Channel for ticket logs archive_channel_id: Channel for archived transcripts - staff_roles: Global staff roles + staff_roles: Global staff roles (can manage tickets, access analytics, etc.) + config_roles: Roles that can access the configuration panel (/ticketconfig) max_tickets_per_user: Global max tickets per user auto_close_enabled: Whether auto-close is enabled auto_close_days: Days before auto-close @@ -292,6 +293,7 @@ class GuildTicketConfig: log_channel_id: Optional[int] = None archive_channel_id: Optional[int] = None staff_roles: List[str] = field(default_factory=list) + config_roles: List[str] = field(default_factory=list) max_tickets_per_user: int = 5 auto_close_enabled: bool = False auto_close_days: int = 7 @@ -311,6 +313,7 @@ class GuildTicketConfig: "log_channel_id": self.log_channel_id, "archive_channel_id": self.archive_channel_id, "staff_roles": self.staff_roles, + "config_roles": self.config_roles, "max_tickets_per_user": self.max_tickets_per_user, "auto_close_enabled": self.auto_close_enabled, "auto_close_days": self.auto_close_days, @@ -333,6 +336,7 @@ class GuildTicketConfig: log_channel_id=data.get("log_channel_id"), archive_channel_id=data.get("archive_channel_id"), staff_roles=data.get("staff_roles", []), + config_roles=data.get("config_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), diff --git a/commandes/ticket/staff_analytics.py b/commandes/ticket/staff_analytics.py new file mode 100644 index 0000000..0c53496 --- /dev/null +++ b/commandes/ticket/staff_analytics.py @@ -0,0 +1,466 @@ +""" +Staff Analytics Module +====================== +Advanced analytics for individual staff performance and ratings. +""" +import io +from datetime import datetime, timedelta +from collections import defaultdict +from typing import List, Dict, Any, Optional + +import discord +from PIL import Image, ImageDraw, ImageFont + +from .data.storage import get_storage +from .data.models import TicketData, TicketStatus + + +class StaffAnalytics: + """Handles staff-specific analytics and visualizations""" + + def __init__(self, bot): + self.bot = bot + self.storage = get_storage() + + async def get_staff_list(self, guild_id: int) -> List[Dict[str, Any]]: + """Get list of staff members with their stats""" + config = self.storage.load_config(guild_id) + tickets = self.storage.get_guild_tickets(guild_id) + + staff_stats = defaultdict(lambda: { + 'user_id': 0, + 'name': 'Unknown', + 'tickets_claimed': 0, + 'tickets_closed': 0, + 'average_rating': 0, + 'total_ratings': 0, + 'response_times': [], + 'ratings': [], + 'categories': defaultdict(int) + }) + + for ticket in tickets: + if ticket.claimed_by: + staff_id = ticket.claimed_by + staff_stats[staff_id]['user_id'] = staff_id + staff_stats[staff_id]['tickets_claimed'] += 1 + + if ticket.status == TicketStatus.CLOSED: + staff_stats[staff_id]['tickets_closed'] += 1 + + if ticket.rating: + staff_stats[staff_id]['ratings'].append(ticket.rating) + staff_stats[staff_id]['total_ratings'] += 1 + + # Calculate response time + if ticket.created_at: + claimed_time = None + for msg in ticket.messages: + if msg.author_id == staff_id and msg.is_staff: + claimed_time = msg.timestamp + break + + if claimed_time: + response_time = (claimed_time - ticket.created_at).total_seconds() / 60 + staff_stats[staff_id]['response_times'].append(response_time) + + # Category stats + staff_stats[staff_id]['categories'][ticket.category_id] += 1 + + # Calculate averages and get names + result = [] + for staff_id, stats in staff_stats.items(): + if stats['ratings']: + stats['average_rating'] = sum(stats['ratings']) / len(stats['ratings']) + + if stats['response_times']: + stats['average_response_time'] = sum(stats['response_times']) / len(stats['response_times']) + else: + stats['average_response_time'] = 0 + + # Get user name + try: + user = self.bot.get_user(staff_id) + if user: + stats['name'] = str(user) + else: + # Try to get from guild + guild = self.bot.get_guild(guild_id) + if guild: + member = guild.get_member(staff_id) + if member: + stats['name'] = str(member) + except: + pass + + result.append(stats) + + # Sort by tickets claimed + result.sort(key=lambda x: x['tickets_claimed'], reverse=True) + return result + + def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO: + """Create a comprehensive staff rating chart""" + # Filter staff with ratings + rated_staff = [s for s in staff_list if s['total_ratings'] > 0] + + if not rated_staff: + # Return empty chart + img = Image.new('RGB', (800, 400), '#2F3136') + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + # Image dimensions + width, height = 1200, 700 + img = Image.new('RGB', (width, height), '#36393F') # Discord dark theme + draw = ImageDraw.Draw(img) + + # Try to load fonts with better Unicode support + try: + # Try different font paths for better Unicode support + font_paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/System/Library/Fonts/Arial.ttf", + "arial.ttf" + ] + font_title = None + font_label = None + font_value = None + + for font_path in font_paths: + try: + font_title = ImageFont.truetype(font_path, 24) + font_label = ImageFont.truetype(font_path, 16) + font_value = ImageFont.truetype(font_path, 14) + break + except: + continue + + if not font_title: + raise Exception("No suitable font found") + + except: + # Fallback to default with better handling + font_title = ImageFont.load_default() + font_label = ImageFont.load_default() + font_value = ImageFont.load_default() + + # Title with better styling + title = "Évaluations des Membres du Staff" + # Add background for title + title_bbox = draw.textbbox((0, 0), title, font=font_title) + title_width = title_bbox[2] - title_bbox[0] + title_x = (width - title_width) // 2 + draw.rectangle([title_x - 20, 15, title_x + title_width + 20, 55], fill='#5865F2', outline='#FFFFFF', width=2) + draw.text((width//2, 35), title, fill='white', font=font_title, anchor='mm') + + # Chart dimensions with better spacing + chart_left = 200 + chart_right = width - 100 + chart_top = 100 + chart_bottom = height - 200 + chart_width = chart_right - chart_left + chart_height = chart_bottom - chart_top + + # Prepare data - limit to top 8 for better readability + top_staff = sorted(rated_staff, key=lambda x: x['average_rating'], reverse=True)[:8] + names = [] + for s in top_staff: + name = s['name'] + # Handle Unicode properly + try: + # Ensure proper encoding + name = name.encode('utf-8').decode('utf-8') + except: + name = str(name) + # Truncate long names + if len(name) > 12: + name = name[:12] + '...' + names.append(name) + + ratings = [s['average_rating'] for s in top_staff] + counts = [s['total_ratings'] for s in top_staff] + + if not ratings: + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + max_rating = 5 + bar_width = min(80, chart_width // len(ratings)) if ratings else 1 + spacing = 20 + + # Draw background grid + for i in range(6): + y = chart_bottom - (i * chart_height // 5) + draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1) + # Rating labels + draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm') + + # Draw bars with gradient effect + for i, (name, rating, count) in enumerate(zip(names, ratings, counts)): + bar_height = int((rating / max_rating) * chart_height) + + x1 = chart_left + i * (bar_width + spacing) + spacing//2 + y1 = chart_bottom - bar_height + x2 = x1 + bar_width + y2 = chart_bottom + + # Bar color based on rating with better colors + if rating >= 4.5: + color = '#57F287' # Green + shadow_color = '#4CAF50' + elif rating >= 3.5: + color = '#FEE75C' # Yellow + shadow_color = '#FFC107' + elif rating >= 2.5: + color = '#FAA61A' # Orange + shadow_color = '#FF9800' + else: + color = '#ED4245' # Red + shadow_color = '#F44336' + + # Draw shadow for 3D effect + draw.rectangle([x1+2, y1+2, x2+2, y2], fill=shadow_color, outline=shadow_color) + + # Main bar + draw.rectangle([x1, y1, x2, y2], fill=color, outline='#FFFFFF', width=2) + + # Rating value on top with background + rating_text = f"{rating:.1f}⭐" + text_bbox = draw.textbbox((0, 0), rating_text, font=font_value) + text_width = text_bbox[2] - text_bbox[0] + text_x = x1 + bar_width//2 + draw.rectangle([text_x - text_width//2 - 5, y1 - 25, text_x + text_width//2 + 5, y1 - 5], fill='#36393F', outline='#FFFFFF', width=1) + draw.text((text_x, y1 - 15), rating_text, fill='white', font=font_value, anchor='mm') + + # Count below with background + count_text = f"({count})" + count_bbox = draw.textbbox((0, 0), count_text, font=font_value) + count_width = count_bbox[2] - count_bbox[0] + draw.rectangle([text_x - count_width//2 - 5, y2 + 5, text_x + count_width//2 + 5, y2 + 25], fill='#36393F', outline='#FFFFFF', width=1) + draw.text((text_x, y2 + 15), count_text, fill='white', font=font_value, anchor='mm') + + # Draw staff names with better positioning + for i, name in enumerate(names): + x = chart_left + i * (bar_width + spacing) + bar_width//2 + spacing//2 + + # Handle long names by splitting + lines = [] + if len(name) > 8: + # Split at spaces if possible + words = name.split() + current_line = "" + for word in words: + if len(current_line + word) < 8: + current_line += word + " " + else: + lines.append(current_line.strip()) + current_line = word + " " + if current_line: + lines.append(current_line.strip()) + if not lines: + lines = [name[:8] + '...'] + else: + lines = [name] + + y_offset = chart_bottom + 40 + for line in lines: + # Background for name + name_bbox = draw.textbbox((0, 0), line, font=font_label) + name_width = name_bbox[2] - name_bbox[0] + draw.rectangle([x - name_width//2 - 5, y_offset - 5, x + name_width//2 + 5, y_offset + 15], fill='#36393F', outline='#FFFFFF', width=1) + draw.text((x, y_offset + 5), line, fill='white', font=font_label, anchor='mm') + y_offset += 20 + + # Y-axis label + draw.text((chart_left - 120, height//2), 'Note Moyenne', fill='white', font=font_label, anchor='mm') + + # Enhanced legend with better positioning + legend_x = chart_right - 250 + legend_y = chart_top + 50 + + # Legend background + draw.rectangle([legend_x - 20, legend_y - 10, legend_x + 230, legend_y + 120], fill='#36393F', outline='#FFFFFF', width=2) + + draw.text((legend_x, legend_y), "LĂ©gende des Notes:", fill='white', font=font_label) + + legend_items = [ + ("🟱 Excellent", "4.5+", '#57F287'), + ("🟡 Bon", "3.5-4.4", '#FEE75C'), + ("🟠 Moyen", "2.5-3.4", '#FAA61A'), + ("🔮 À amĂ©liorer", "<2.5", '#ED4245') + ] + + for i, (emoji_text, range_text, color) in enumerate(legend_items): + y_pos = legend_y + 25 + i * 20 + draw.text((legend_x, y_pos), f"{emoji_text} {range_text}", fill=color, font=font_value) + + # Add some stats at the bottom + stats_text = f"Total Ă©valuĂ©: {len(rated_staff)} membres ‱ Moyenne gĂ©nĂ©rale: {sum(ratings)/len(ratings):.1f}⭐" + draw.text((width//2, height - 30), stats_text, fill='#B9BBBE', font=font_value, anchor='mm') + + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO: + """Create detailed chart for individual staff member""" + width, height = 800, 500 + img = Image.new('RGB', (width, height), '#2F3136') + draw = ImageDraw.Draw(img) + + try: + font_title = ImageFont.truetype("arial.ttf", 18) + font_label = ImageFont.truetype("arial.ttf", 14) + font_value = ImageFont.truetype("arial.ttf", 12) + except: + font_title = ImageFont.load_default() + font_label = ImageFont.load_default() + font_value = ImageFont.load_default() + + # Title + title = f"Statistiques de {staff_data['name']}" + draw.text((width//2, 30), title, fill='white', font=font_title, anchor='mm') + + # Basic stats + stats_y = 80 + draw.text((50, stats_y), f"Tickets rĂ©clamĂ©s: {staff_data['tickets_claimed']}", fill='white', font=font_label) + draw.text((50, stats_y + 25), f"Tickets fermĂ©s: {staff_data['tickets_closed']}", fill='white', font=font_label) + + if staff_data['total_ratings'] > 0: + avg_rating = staff_data['average_rating'] + color = '#57F287' if avg_rating >= 4 else '#FEE75C' if avg_rating >= 3 else '#ED4245' + draw.text((50, stats_y + 50), f"Note moyenne: {avg_rating:.1f}⭐ ({staff_data['total_ratings']} Ă©valuations)", fill=color, font=font_label) + + if staff_data['response_times']: + avg_response = staff_data['average_response_time'] + draw.text((50, stats_y + 75), f"Temps de rĂ©ponse moyen: {avg_response:.1f} min", fill='white', font=font_label) + + # Rating distribution if available + if staff_data['ratings']: + chart_left = 400 + chart_top = 150 + chart_width = 350 + chart_height = 200 + + # Count ratings + rating_counts = defaultdict(int) + for rating in staff_data['ratings']: + rating_counts[rating] += 1 + + max_count = max(rating_counts.values()) if rating_counts else 1 + + # Draw mini bars + bar_width = chart_width // 5 + for i in range(1, 6): + count = rating_counts[i] + if max_count > 0: + bar_height = int((count / max_count) * chart_height) + else: + bar_height = 0 + + x1 = chart_left + (i-1) * bar_width + 10 + y1 = chart_top + chart_height - bar_height + x2 = x1 + bar_width - 20 + y2 = chart_top + chart_height + + draw.rectangle([x1, y1, x2, y2], fill='#5865F2', outline='#FFFFFF', width=1) + draw.text((x1 + (x2-x1)//2, y1 - 15), str(count), fill='white', font=font_value, anchor='mm') + + # Labels + for i in range(1, 6): + x = chart_left + (i-1) * bar_width + bar_width//2 + draw.text((x, chart_top + chart_height + 20), f"{i}⭐", fill='white', font=font_label, anchor='mm') + + draw.text((chart_left + chart_width//2, chart_top - 20), "Distribution des Notes", fill='white', font=font_label, anchor='mm') + + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return buf + + +class StaffStatsView(discord.ui.View): + """View for selecting individual staff member stats""" + + def __init__(self, bot, staff_list: List[Dict[str, Any]], analytics: StaffAnalytics): + super().__init__(timeout=300) + self.bot = bot + self.staff_list = staff_list + self.analytics = analytics + self._build_select() + + def _build_select(self): + """Build staff selection dropdown""" + options = [] + for staff in self.staff_list[:25]: # Discord limit + label = f"{staff['name'][:20]} ({staff['tickets_claimed']} tickets)" + description = f"Note: {staff['average_rating']:.1f}⭐" if staff['total_ratings'] > 0 else "Aucune Ă©valuation" + + options.append( + discord.SelectOption( + label=label, + value=str(staff['user_id']), + description=description[:50] + ) + ) + + if options: + select = discord.ui.Select( + placeholder="SĂ©lectionnez un membre du staff...", + options=options + ) + select.callback = self._staff_selected + self.add_item(select) + + async def _staff_selected(self, interaction: discord.Interaction): + """Handle staff selection""" + staff_id = int(interaction.data["values"][0]) + staff_data = next((s for s in self.staff_list if s['user_id'] == staff_id), None) + + if not staff_data: + await interaction.response.send_message("Membre du staff introuvable.", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + # Create individual chart + chart = self.analytics.create_individual_staff_chart(staff_data) + + embed = discord.Embed( + title=f"📊 Statistiques de {staff_data['name']}", + description="Performances dĂ©taillĂ©es du membre du staff", + color=discord.Color.blue(), + timestamp=datetime.now() + ) + + embed.add_field( + name="📈 ActivitĂ©", + value=f"**Tickets rĂ©clamĂ©s:** {staff_data['tickets_claimed']}\n" + f"**Tickets fermĂ©s:** {staff_data['tickets_closed']}\n" + f"**Taux de rĂ©solution:** {staff_data['tickets_closed']/staff_data['tickets_claimed']*100:.1f}%" if staff_data['tickets_claimed'] > 0 else "N/A", + inline=True + ) + + if staff_data['total_ratings'] > 0: + embed.add_field( + name="⭐ Évaluations", + value=f"**Note moyenne:** {staff_data['average_rating']:.1f}/5\n" + f"**Total d'Ă©valuations:** {staff_data['total_ratings']}", + inline=True + ) + + if staff_data['response_times']: + embed.add_field( + name="⏱ Performance", + value=f"**Temps de rĂ©ponse moyen:** {staff_data['average_response_time']:.1f} min", + inline=True + ) + + await interaction.followup.send(embed=embed, file=discord.File(chart, 'staff_stats.png'), ephemeral=True) diff --git a/commandes/ticket/views/__init__.py b/commandes/ticket/views/__init__.py deleted file mode 100644 index ada503d..0000000 --- a/commandes/ticket/views/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -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", -] - diff --git a/commandes/ticket/views/admin.py b/commandes/ticket/views/admin.py deleted file mode 100644 index ada7b99..0000000 --- a/commandes/ticket/views/admin.py +++ /dev/null @@ -1,522 +0,0 @@ -""" -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 - ) - diff --git a/commandes/ticket/views/creation.py b/commandes/ticket/views/creation.py deleted file mode 100644 index 155ad8e..0000000 --- a/commandes/ticket/views/creation.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -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) - diff --git a/commandes/whitelist_monitor.py b/commandes/whitelist_monitor.py index b798df9..53586bd 100755 --- a/commandes/whitelist_monitor.py +++ b/commandes/whitelist_monitor.py @@ -28,32 +28,44 @@ class WhitelistMonitor(commands.Cog): discord.Permissions.view_audit_log ] - def load_whitelist(self) -> List[str]: + def load_whitelist(self) -> Dict[str, List[str]]: """Load whitelist from JSON file.""" if not os.path.exists(self.whitelist_file): - return [] + return {} try: with open(self.whitelist_file, "r", encoding="utf-8") as f: - return json.load(f) + raw_data = json.load(f) + # Convert all user IDs to strings for consistent comparison + result = {} + for guild_id, user_ids in raw_data.items(): + result[str(guild_id)] = [str(uid) for uid in user_ids] + return result except (json.JSONDecodeError, FileNotFoundError): - return [] + return {} - def save_whitelist(self, whitelist: List[str]): + def save_whitelist(self, whitelist: Dict[str, List[str]]): """Save whitelist to JSON file.""" with open(self.whitelist_file, "w", encoding="utf-8") as f: json.dump(whitelist, f, ensure_ascii=False, indent=4) - def is_whitelisted(self, user_id: int) -> bool: - """Check if user is whitelisted.""" + def is_whitelisted(self, guild_id: int, user_id: int) -> bool: + """Check if user is whitelisted for a specific guild.""" whitelist = self.load_whitelist() - return str(user_id) in whitelist + guild_key = str(guild_id) + user_key = str(user_id) + return guild_key in whitelist and user_key in whitelist[guild_key] - def remove_from_whitelist(self, user_id: int) -> bool: - """Remove user from whitelist.""" + def remove_from_whitelist(self, guild_id: int, user_id: int) -> bool: + """Remove user from whitelist for a specific guild.""" whitelist = self.load_whitelist() - user_str = str(user_id) - if user_str in whitelist: - whitelist.remove(user_str) + guild_key = str(guild_id) + user_key = str(user_id) + + if guild_key in whitelist and user_key in whitelist[guild_key]: + whitelist[guild_key].remove(user_key) + # Clean up empty guild entries + if not whitelist[guild_key]: + del whitelist[guild_key] self.save_whitelist(whitelist) return True return False @@ -148,7 +160,7 @@ class WhitelistMonitor(commands.Cog): if before.roles == after.roles: return - if not self.is_whitelisted(after.id): + if not self.is_whitelisted(after.guild.id, after.id): return # Find removed roles @@ -163,8 +175,8 @@ class WhitelistMonitor(commands.Cog): @commands.Cog.listener() async def on_member_remove(self, member: discord.Member): """Auto-remove from whitelist when member leaves server.""" - if self.is_whitelisted(member.id): - removed = self.remove_from_whitelist(member.id) + if self.is_whitelisted(member.guild.id, member.id): + removed = self.remove_from_whitelist(member.guild.id, member.id) if removed: # Log the removal try: @@ -225,7 +237,7 @@ class WhitelistRemovalView(View): return # Remove from whitelist - removed = cog.remove_from_whitelist(self.user_id) + removed = cog.remove_from_whitelist(self.guild_id, self.user_id) if removed: # Get user info diff --git a/data/logs/messages_2026-01-10.json b/data/logs/messages_2026-01-10.json new file mode 100644 index 0000000..e4dede0 --- /dev/null +++ b/data/logs/messages_2026-01-10.json @@ -0,0 +1,25 @@ +[ + { + "timestamp": "2026-01-10T00:57:48.563188", + "event_type": "edited", + "message_id": 1459335326660300935, + "channel_id": 1369669999677145098, + "channel_name": "â•­đŸ’Źăƒ»gĂ©nĂ©ral", + "author_id": 971446412690722826, + "author_name": "gameurpro12", + "author_roles": [ + 1369669999345537145, + 1369769836028231730, + 1369669999366770723, + 1403041729330024508, + 1369669999366770726 + ], + "content": "Non c'est gentil t'inqiutes", + "attachments": [], + "embeds": [], + "details": { + "before_content": "Non c'est gentil t'inqiutes", + "after_content": "Non c'est gentil t’inquiĂštes" + } + } +] \ No newline at end of file diff --git a/data/tickets/backups/20260104_111915_config.json b/data/tickets/backups/20260104_111915_config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/data/tickets/backups/20260104_111915_config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/tickets/backups/20260104_111918_config.json b/data/tickets/backups/20260104_111918_config.json new file mode 100644 index 0000000..ef1c4e9 --- /dev/null +++ b/data/tickets/backups/20260104_111918_config.json @@ -0,0 +1,20 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": false, + "categories": [], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_111959_config.json b/data/tickets/backups/20260104_111959_config.json new file mode 100644 index 0000000..9c6fa1f --- /dev/null +++ b/data/tickets/backups/20260104_111959_config.json @@ -0,0 +1,20 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_112018_config.json b/data/tickets/backups/20260104_112018_config.json new file mode 100644 index 0000000..e285a42 --- /dev/null +++ b/data/tickets/backups/20260104_112018_config.json @@ -0,0 +1,36 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_112212_1457318005355708500.json b/data/tickets/backups/20260104_112212_1457318005355708500.json new file mode 100644 index 0000000..104771a --- /dev/null +++ b/data/tickets/backups/20260104_112212_1457318005355708500.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1457318005355708500, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-04T11:21:28.278837", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "La flemme", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_112633_config.json b/data/tickets/backups/20260104_112633_config.json new file mode 100644 index 0000000..4d78916 --- /dev/null +++ b/data/tickets/backups/20260104_112633_config.json @@ -0,0 +1,38 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537153" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_112638_config.json b/data/tickets/backups/20260104_112638_config.json new file mode 100644 index 0000000..4efd48b --- /dev/null +++ b/data/tickets/backups/20260104_112638_config.json @@ -0,0 +1,38 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": false, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537153" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260104_112704_config.json b/data/tickets/backups/20260104_112704_config.json new file mode 100644 index 0000000..4d78916 --- /dev/null +++ b/data/tickets/backups/20260104_112704_config.json @@ -0,0 +1,38 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537153" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260107_191211_1457319573421293740.json b/data/tickets/backups/20260107_191211_1457319573421293740.json new file mode 100644 index 0000000..51cb838 --- /dev/null +++ b/data/tickets/backups/20260107_191211_1457319573421293740.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1457319573421293740, + "guild_id": 1369669999345537145, + "user_id": 353942721997832195, + "category_id": "91522e2c", + "status": "open", + "priority": "normal", + "created_at": "2026-01-04T11:27:42.144797", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "allah", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260109_215121_1459288440410738688.json b/data/tickets/backups/20260109_215121_1459288440410738688.json new file mode 100644 index 0000000..342304d --- /dev/null +++ b/data/tickets/backups/20260109_215121_1459288440410738688.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459288440410738688, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-09T21:51:16.713690", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "Jfjdid", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260109_215153_1459288440410738688.json b/data/tickets/backups/20260109_215153_1459288440410738688.json new file mode 100644 index 0000000..8ee66e8 --- /dev/null +++ b/data/tickets/backups/20260109_215153_1459288440410738688.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459288440410738688, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "claimed", + "priority": "normal", + "created_at": "2026-01-09T21:51:16.713690", + "closed_at": null, + "claimed_by": 971446412690722826, + "messages": [], + "reason": "Jfjdid", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260109_235907_config.json b/data/tickets/backups/20260109_235907_config.json new file mode 100644 index 0000000..53f23f7 --- /dev/null +++ b/data/tickets/backups/20260109_235907_config.json @@ -0,0 +1,53 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537153" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_111553_config.json b/data/tickets/backups/20260110_111553_config.json new file mode 100644 index 0000000..88c3253 --- /dev/null +++ b/data/tickets/backups/20260110_111553_config.json @@ -0,0 +1,53 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": false, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537153" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_112750_config.json b/data/tickets/backups/20260110_112750_config.json new file mode 100644 index 0000000..d16cb89 --- /dev/null +++ b/data/tickets/backups/20260110_112750_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": false, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_112818_config.json b/data/tickets/backups/20260110_112818_config.json new file mode 100644 index 0000000..0b8f539 --- /dev/null +++ b/data/tickets/backups/20260110_112818_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_112822_config.json b/data/tickets/backups/20260110_112822_config.json new file mode 100644 index 0000000..d16cb89 --- /dev/null +++ b/data/tickets/backups/20260110_112822_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": false, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_114223_config.json b/data/tickets/backups/20260110_114223_config.json new file mode 100644 index 0000000..0b8f539 --- /dev/null +++ b/data/tickets/backups/20260110_114223_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_114828_config.json b/data/tickets/backups/20260110_114828_config.json new file mode 100644 index 0000000..53e7da5 --- /dev/null +++ b/data/tickets/backups/20260110_114828_config.json @@ -0,0 +1,52 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_114844_config.json b/data/tickets/backups/20260110_114844_config.json new file mode 100644 index 0000000..0b8f539 --- /dev/null +++ b/data/tickets/backups/20260110_114844_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_120233_config.json b/data/tickets/backups/20260110_120233_config.json new file mode 100644 index 0000000..53e7da5 --- /dev/null +++ b/data/tickets/backups/20260110_120233_config.json @@ -0,0 +1,52 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_120255_config.json b/data/tickets/backups/20260110_120255_config.json new file mode 100644 index 0000000..0b8f539 --- /dev/null +++ b/data/tickets/backups/20260110_120255_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_120754_config.json b/data/tickets/backups/20260110_120754_config.json new file mode 100644 index 0000000..53e7da5 --- /dev/null +++ b/data/tickets/backups/20260110_120754_config.json @@ -0,0 +1,52 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_120804_config.json b/data/tickets/backups/20260110_120804_config.json new file mode 100644 index 0000000..0b8f539 --- /dev/null +++ b/data/tickets/backups/20260110_120804_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_121335_config.json b/data/tickets/backups/20260110_121335_config.json new file mode 100644 index 0000000..53e7da5 --- /dev/null +++ b/data/tickets/backups/20260110_121335_config.json @@ -0,0 +1,52 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_132601_config.json b/data/tickets/backups/20260110_132601_config.json new file mode 100644 index 0000000..ac11d63 --- /dev/null +++ b/data/tickets/backups/20260110_132601_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_132641_config.json b/data/tickets/backups/20260110_132641_config.json new file mode 100644 index 0000000..bd7c147 --- /dev/null +++ b/data/tickets/backups/20260110_132641_config.json @@ -0,0 +1,56 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_132713_config.json b/data/tickets/backups/20260110_132713_config.json new file mode 100644 index 0000000..ac11d63 --- /dev/null +++ b/data/tickets/backups/20260110_132713_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_132734_config.json b/data/tickets/backups/20260110_132734_config.json new file mode 100644 index 0000000..bd7c147 --- /dev/null +++ b/data/tickets/backups/20260110_132734_config.json @@ -0,0 +1,56 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_132828_config.json b/data/tickets/backups/20260110_132828_config.json new file mode 100644 index 0000000..ac11d63 --- /dev/null +++ b/data/tickets/backups/20260110_132828_config.json @@ -0,0 +1,54 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_145138_config.json b/data/tickets/backups/20260110_145138_config.json new file mode 100644 index 0000000..f65f67b --- /dev/null +++ b/data/tickets/backups/20260110_145138_config.json @@ -0,0 +1,56 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369669999345537146" + ], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_145721_1459542651245166695.json b/data/tickets/backups/20260110_145721_1459542651245166695.json new file mode 100644 index 0000000..258375c --- /dev/null +++ b/data/tickets/backups/20260110_145721_1459542651245166695.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459542651245166695, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T14:41:25.312310", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "dzqdqzdqz", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_145832_1459546915153772545.json b/data/tickets/backups/20260110_145832_1459546915153772545.json new file mode 100644 index 0000000..2fba0cb --- /dev/null +++ b/data/tickets/backups/20260110_145832_1459546915153772545.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459546915153772545, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T14:58:21.774679", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "eefefsfsefes", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_150355_1459548273877586033.json b/data/tickets/backups/20260110_150355_1459548273877586033.json new file mode 100644 index 0000000..b6d82c2 --- /dev/null +++ b/data/tickets/backups/20260110_150355_1459548273877586033.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548273877586033, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T15:03:45.750801", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "fezfesefs", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_150439_1459548466182361098.json b/data/tickets/backups/20260110_150439_1459548466182361098.json new file mode 100644 index 0000000..8b2f75a --- /dev/null +++ b/data/tickets/backups/20260110_150439_1459548466182361098.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548466182361098, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T15:04:31.642858", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "ezefzzfefz", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_150505_config.json b/data/tickets/backups/20260110_150505_config.json new file mode 100644 index 0000000..bd7c147 --- /dev/null +++ b/data/tickets/backups/20260110_150505_config.json @@ -0,0 +1,56 @@ +{ + "1369669999345537145": { + "guild_id": 1369669999345537145, + "enabled": true, + "categories": [ + { + "id": "3b0b9bc1", + "name": "Mon cul", + "description": "Mon Anus", + "emoji": "đŸŽ«", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + }, + { + "id": "91522e2c", + "name": "claudo", + "description": "un clochard", + "emoji": "📋", + "color": "#3498db", + "staff_roles": [], + "welcome_message": "Merci d'avoir créé un ticket. Notre Ă©quipe va vous rĂ©pondre sous peu.", + "require_reason": false, + "max_tickets_per_user": 5, + "auto_close_days": 7, + "priority_enabled": true, + "allow_claims": true, + "survey_enabled": true + } + ], + "default_category": null, + "log_channel_id": null, + "archive_channel_id": null, + "staff_roles": [ + "1369769836028231730" + ], + "config_roles": [ + "1369769836028231730" + ], + "max_tickets_per_user": 5, + "auto_close_enabled": false, + "auto_close_days": 7, + "transcript_enabled": true, + "claim_enabled": true, + "survey_enabled": true, + "panel_channel_id": null, + "welcome_enabled": true, + "priority_enabled": true + } +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_150528_1459548635716124943.json b/data/tickets/backups/20260110_150528_1459548635716124943.json new file mode 100644 index 0000000..3d5a4da --- /dev/null +++ b/data/tickets/backups/20260110_150528_1459548635716124943.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548635716124943, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T15:05:12.045145", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "effefes", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_150903_1459549570034962619.json b/data/tickets/backups/20260110_150903_1459549570034962619.json new file mode 100644 index 0000000..f6ee9a9 --- /dev/null +++ b/data/tickets/backups/20260110_150903_1459549570034962619.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459549570034962619, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T15:08:54.901938", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "esffefe", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_151011_1459544021482475635.json b/data/tickets/backups/20260110_151011_1459544021482475635.json new file mode 100644 index 0000000..63db3c7 --- /dev/null +++ b/data/tickets/backups/20260110_151011_1459544021482475635.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459544021482475635, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T14:46:52.054955", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "rgrdgrdrgd", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/backups/20260110_151027_1459549931672174818.json b/data/tickets/backups/20260110_151027_1459549931672174818.json new file mode 100644 index 0000000..a81f271 --- /dev/null +++ b/data/tickets/backups/20260110_151027_1459549931672174818.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459549931672174818, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T15:10:21.227459", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "efzfefs", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/tickets/1457318005355708500.json b/data/tickets/tickets/1457318005355708500.json new file mode 100644 index 0000000..327ac49 --- /dev/null +++ b/data/tickets/tickets/1457318005355708500.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1457318005355708500, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-04T11:21:28.278837", + "closed_at": "2026-01-04T11:22:12.720609", + "claimed_by": null, + "messages": [], + "reason": "La flemme", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/tickets/1457319573421293740.json b/data/tickets/tickets/1457319573421293740.json new file mode 100644 index 0000000..446d5e3 --- /dev/null +++ b/data/tickets/tickets/1457319573421293740.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1457319573421293740, + "guild_id": 1369669999345537145, + "user_id": 353942721997832195, + "category_id": "91522e2c", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-04T11:27:42.144797", + "closed_at": "2026-01-07T19:12:11.339134", + "claimed_by": null, + "messages": [], + "reason": "allah", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/tickets/1459288440410738688.json b/data/tickets/tickets/1459288440410738688.json new file mode 100644 index 0000000..f89f63c --- /dev/null +++ b/data/tickets/tickets/1459288440410738688.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459288440410738688, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-09T21:51:16.713690", + "closed_at": "2026-01-09T21:51:53.877425", + "claimed_by": 971446412690722826, + "messages": [], + "reason": "Jfjdid", + "transcript_path": null, + "rating": 4, + "feedback": "C’est nul" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459542651245166695.json b/data/tickets/tickets/1459542651245166695.json new file mode 100644 index 0000000..0d0e0df --- /dev/null +++ b/data/tickets/tickets/1459542651245166695.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459542651245166695, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T14:41:25.312310", + "closed_at": "2026-01-10T14:57:21.227412", + "claimed_by": null, + "messages": [], + "reason": "dzqdqzdqz", + "transcript_path": null, + "rating": 5, + "feedback": "gv" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459544021482475635.json b/data/tickets/tickets/1459544021482475635.json new file mode 100644 index 0000000..e5322fb --- /dev/null +++ b/data/tickets/tickets/1459544021482475635.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459544021482475635, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T14:46:52.054955", + "closed_at": "2026-01-10T15:10:11.276756", + "claimed_by": null, + "messages": [], + "reason": "rgrdgrdrgd", + "transcript_path": null, + "rating": 5, + "feedback": "effse" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459546304333086802.json b/data/tickets/tickets/1459546304333086802.json new file mode 100644 index 0000000..f2cf3c8 --- /dev/null +++ b/data/tickets/tickets/1459546304333086802.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459546304333086802, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "open", + "priority": "normal", + "created_at": "2026-01-10T14:55:56.156555", + "closed_at": null, + "claimed_by": null, + "messages": [], + "reason": "eqqe", + "transcript_path": null, + "rating": null, + "feedback": null +} \ No newline at end of file diff --git a/data/tickets/tickets/1459546915153772545.json b/data/tickets/tickets/1459546915153772545.json new file mode 100644 index 0000000..c02dcbf --- /dev/null +++ b/data/tickets/tickets/1459546915153772545.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459546915153772545, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T14:58:21.774679", + "closed_at": "2026-01-10T14:58:32.294636", + "claimed_by": null, + "messages": [], + "reason": "eefefsfsefes", + "transcript_path": null, + "rating": 5, + "feedback": "dqdzdq" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459548273877586033.json b/data/tickets/tickets/1459548273877586033.json new file mode 100644 index 0000000..6e1bfce --- /dev/null +++ b/data/tickets/tickets/1459548273877586033.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548273877586033, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T15:03:45.750801", + "closed_at": "2026-01-10T15:03:55.900147", + "claimed_by": null, + "messages": [], + "reason": "fezfesefs", + "transcript_path": null, + "rating": 5, + "feedback": "fessfeesf" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459548466182361098.json b/data/tickets/tickets/1459548466182361098.json new file mode 100644 index 0000000..f833fe5 --- /dev/null +++ b/data/tickets/tickets/1459548466182361098.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548466182361098, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T15:04:31.642858", + "closed_at": "2026-01-10T15:04:39.752923", + "claimed_by": null, + "messages": [], + "reason": "ezefzzfefz", + "transcript_path": null, + "rating": 5, + "feedback": "thfthfhtf" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459548635716124943.json b/data/tickets/tickets/1459548635716124943.json new file mode 100644 index 0000000..6f154b3 --- /dev/null +++ b/data/tickets/tickets/1459548635716124943.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459548635716124943, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T15:05:12.045145", + "closed_at": "2026-01-10T15:05:28.167741", + "claimed_by": null, + "messages": [], + "reason": "effefes", + "transcript_path": null, + "rating": 5, + "feedback": "zffee" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459549570034962619.json b/data/tickets/tickets/1459549570034962619.json new file mode 100644 index 0000000..5400efe --- /dev/null +++ b/data/tickets/tickets/1459549570034962619.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459549570034962619, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T15:08:54.901938", + "closed_at": "2026-01-10T15:09:03.852536", + "claimed_by": null, + "messages": [], + "reason": "esffefe", + "transcript_path": null, + "rating": 5, + "feedback": "fesfefesf" +} \ No newline at end of file diff --git a/data/tickets/tickets/1459549931672174818.json b/data/tickets/tickets/1459549931672174818.json new file mode 100644 index 0000000..38fa02c --- /dev/null +++ b/data/tickets/tickets/1459549931672174818.json @@ -0,0 +1,16 @@ +{ + "channel_id": 1459549931672174818, + "guild_id": 1369669999345537145, + "user_id": 971446412690722826, + "category_id": "3b0b9bc1", + "status": "closed", + "priority": "normal", + "created_at": "2026-01-10T15:10:21.227459", + "closed_at": "2026-01-10T15:10:27.544313", + "claimed_by": null, + "messages": [], + "reason": "efzfefs", + "transcript_path": null, + "rating": 1, + "feedback": "grgrgrd" +} \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1457319573421293740_20260104_113023.html b/data/tickets/transcripts/ticket_1457319573421293740_20260104_113023.html new file mode 100644 index 0000000..e19788b --- /dev/null +++ b/data/tickets/transcripts/ticket_1457319573421293740_20260104_113023.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1457319573421293740 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1457319573421293740 +
+
+ 📁 CatĂ©gorie: + 91522e2c +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 04/01/2026 11:27 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 3 min +
+
+ Messages + 0 +
+
+ Note + Pas de note +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1457319573421293740_20260104_113027.html b/data/tickets/transcripts/ticket_1457319573421293740_20260104_113027.html new file mode 100644 index 0000000..38a5407 --- /dev/null +++ b/data/tickets/transcripts/ticket_1457319573421293740_20260104_113027.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1457319573421293740 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1457319573421293740 +
+
+ 📁 CatĂ©gorie: + 91522e2c +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 04/01/2026 11:27 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 3 min +
+
+ Messages + 0 +
+
+ Note + Pas de note +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459288440410738688_20260109_215133.html b/data/tickets/transcripts/ticket_1459288440410738688_20260109_215133.html new file mode 100644 index 0000000..58ecd6d --- /dev/null +++ b/data/tickets/transcripts/ticket_1459288440410738688_20260109_215133.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459288440410738688 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459288440410738688 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + claimed +
+
+ 📅 Créé: + 09/01/2026 21:51 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + Pas de note +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459544021482475635_20260110_151011.html b/data/tickets/transcripts/ticket_1459544021482475635_20260110_151011.html new file mode 100644 index 0000000..f9733a5 --- /dev/null +++ b/data/tickets/transcripts/ticket_1459544021482475635_20260110_151011.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459544021482475635 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459544021482475635 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 14:46 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 23 min +
+
+ Messages + 0 +
+
+ Note + ⭐⭐⭐⭐⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459548273877586033_20260110_150355.html b/data/tickets/transcripts/ticket_1459548273877586033_20260110_150355.html new file mode 100644 index 0000000..8adb42f --- /dev/null +++ b/data/tickets/transcripts/ticket_1459548273877586033_20260110_150355.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459548273877586033 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459548273877586033 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 15:03 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + ⭐⭐⭐⭐⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459548466182361098_20260110_150439.html b/data/tickets/transcripts/ticket_1459548466182361098_20260110_150439.html new file mode 100644 index 0000000..b70ca6a --- /dev/null +++ b/data/tickets/transcripts/ticket_1459548466182361098_20260110_150439.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459548466182361098 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459548466182361098 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 15:04 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + ⭐⭐⭐⭐⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459548635716124943_20260110_150528.html b/data/tickets/transcripts/ticket_1459548635716124943_20260110_150528.html new file mode 100644 index 0000000..58f974e --- /dev/null +++ b/data/tickets/transcripts/ticket_1459548635716124943_20260110_150528.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459548635716124943 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459548635716124943 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 15:05 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + ⭐⭐⭐⭐⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459549570034962619_20260110_150903.html b/data/tickets/transcripts/ticket_1459549570034962619_20260110_150903.html new file mode 100644 index 0000000..ab5dfbd --- /dev/null +++ b/data/tickets/transcripts/ticket_1459549570034962619_20260110_150903.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459549570034962619 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459549570034962619 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 15:08 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + ⭐⭐⭐⭐⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/data/tickets/transcripts/ticket_1459549931672174818_20260110_151027.html b/data/tickets/transcripts/ticket_1459549931672174818_20260110_151027.html new file mode 100644 index 0000000..16077b7 --- /dev/null +++ b/data/tickets/transcripts/ticket_1459549931672174818_20260110_151027.html @@ -0,0 +1,210 @@ + + + + + + Transcript - Ticket #1459549931672174818 + + + +
+
+

đŸŽ« Transcript de Ticket

+
+
+ 📌 ID: + 1459549931672174818 +
+
+ 📁 CatĂ©gorie: + 3b0b9bc1 +
+
+ ⚡ PrioritĂ©: + NORMAL +
+
+ 📊 Statut: + open +
+
+ 📅 Créé: + 10/01/2026 15:10 +
+
+
+ +
+

💬 Messages (0)

+ +
+ + +
+

📊 Statistiques

+
+
+ Durée + 0 min +
+
+ Messages + 0 +
+
+ Note + ⭐ +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 70ad9a2..d7ea55f 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ discord.py>=2.3.0 python-dotenv>=1.0.0 +Pillow>=10.0.0