Rework complet du système de ticket § focntinnnement partiel

This commit is contained in:
Mathis 2026-01-10 15:30:51 +01:00
parent 93e2f9bb44
commit dfec6b2418
75 changed files with 6424 additions and 1893 deletions

View file

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

View file

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

View file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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