Première version non terminer du nouveau système de ticket § ajout du système de boutons persistant (non tester)
This commit is contained in:
parent
10173132a2
commit
93e2f9bb44
15 changed files with 3788 additions and 209 deletions
15
commandes/ticket/utils/__init__.py
Normal file
15
commandes/ticket/utils/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
Ticket Utils Module
|
||||
===================
|
||||
Utility functions for the ticket system.
|
||||
"""
|
||||
from .transcript import TranscriptGenerator, get_transcript_generator
|
||||
from .formatters import TicketEmbedFormatter, TicketMessageFormatter
|
||||
|
||||
__all__ = [
|
||||
"TranscriptGenerator",
|
||||
"get_transcript_generator",
|
||||
"TicketEmbedFormatter",
|
||||
"TicketMessageFormatter",
|
||||
]
|
||||
|
||||
453
commandes/ticket/utils/formatters.py
Normal file
453
commandes/ticket/utils/formatters.py
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
"""
|
||||
Ticket Formatters
|
||||
=================
|
||||
Beautiful embed and message formatters for the ticket system.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from discord import Embed, Color, User, Member, Role
|
||||
|
||||
from ..data.models import (
|
||||
TicketData,
|
||||
TicketCategory,
|
||||
TicketStats,
|
||||
GuildTicketConfig,
|
||||
TicketStatus,
|
||||
Priority
|
||||
)
|
||||
|
||||
|
||||
class TicketEmbedFormatter:
|
||||
"""
|
||||
Creates beautiful, consistent embeds for the ticket system.
|
||||
|
||||
Features:
|
||||
- Color-coded by priority
|
||||
- Consistent styling
|
||||
- Rich information display
|
||||
- Translatable text
|
||||
"""
|
||||
|
||||
# Priority colors
|
||||
PRIORITY_COLORS = {
|
||||
Priority.LOW: Color.green(),
|
||||
Priority.NORMAL: Color.blue(),
|
||||
Priority.HIGH: Color.orange(),
|
||||
Priority.URGENT: Color.red()
|
||||
}
|
||||
|
||||
# Status emojis
|
||||
STATUS_EMOJIS = {
|
||||
TicketStatus.OPEN: "🟢",
|
||||
TicketStatus.CLAIMED: "🟡",
|
||||
TicketStatus.CLOSED: "🔴",
|
||||
TicketStatus.ARCHIVED: "⚫"
|
||||
}
|
||||
|
||||
# Priority emojis
|
||||
PRIORITY_EMOJIS = {
|
||||
Priority.LOW: "🟢",
|
||||
Priority.NORMAL: "🔵",
|
||||
Priority.HIGH: "🟠",
|
||||
Priority.URGENT: "🔴"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_priority_color(cls, priority: Priority) -> Color:
|
||||
"""Get embed color based on priority"""
|
||||
return cls.PRIORITY_COLORS.get(priority, Color.blue())
|
||||
|
||||
@classmethod
|
||||
def format_status(cls, status: TicketStatus) -> str:
|
||||
"""Get formatted status with emoji"""
|
||||
emoji = cls.STATUS_EMOJIS.get(status, "⚪")
|
||||
status_names = {
|
||||
TicketStatus.OPEN: "Ouvert",
|
||||
TicketStatus.CLAIMED: "Réclamé",
|
||||
TicketStatus.CLOSED: "Fermé",
|
||||
TicketStatus.ARCHIVED: "Archivé"
|
||||
}
|
||||
return f"{emoji} {status_names.get(status, status.value)}"
|
||||
|
||||
@classmethod
|
||||
def format_priority(cls, priority: Priority) -> str:
|
||||
"""Get formatted priority with emoji"""
|
||||
emoji = cls.PRIORITY_EMOJIS.get(priority, "⚪")
|
||||
priority_names = {
|
||||
Priority.LOW: "Basse",
|
||||
Priority.NORMAL: "Normale",
|
||||
Priority.HIGH: "Haute",
|
||||
Priority.URGENT: "Urgente"
|
||||
}
|
||||
return f"{emoji} {priority_names.get(priority, priority.value)}"
|
||||
|
||||
# --- Ticket Creation Embeds ---
|
||||
|
||||
@classmethod
|
||||
def ticket_created(
|
||||
cls,
|
||||
ticket: TicketData,
|
||||
user: User,
|
||||
category: Optional[TicketCategory] = None,
|
||||
guild_name: str = "Serveur"
|
||||
) -> Embed:
|
||||
"""Embed for when a ticket is created"""
|
||||
embed = Embed(
|
||||
title="🎫 Nouveau Ticket Créé",
|
||||
description=category.welcome_message if category else "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu.",
|
||||
color=cls.get_priority_color(ticket.priority),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
|
||||
|
||||
# Add fields
|
||||
embed.add_field(name="📌 Ticket ID", value=f"`{ticket.channel_id}`", inline=True)
|
||||
embed.add_field(name="👤 Utilisateur", value=user.mention, inline=True)
|
||||
embed.add_field(name="📁 Catégorie", value=category.name if category else ticket.category_id, inline=True)
|
||||
embed.add_field(name="⚡ Priorité", value=cls.format_priority(ticket.priority), inline=True)
|
||||
|
||||
if ticket.reason:
|
||||
embed.add_field(name="📝 Raison", value=ticket.reason, inline=False)
|
||||
|
||||
embed.set_footer(text=f"ID: {ticket.channel_id} | {guild_name}")
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def ticket_closed(
|
||||
cls,
|
||||
ticket: TicketData,
|
||||
closed_by: User,
|
||||
duration_minutes: float,
|
||||
reason: Optional[str] = None
|
||||
) -> Embed:
|
||||
"""Embed for when a ticket is closed"""
|
||||
embed = Embed(
|
||||
title="🔴 Ticket Fermé",
|
||||
description=f"Ce ticket a été fermé par {closed_by.mention}",
|
||||
color=Color.red(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.add_field(name="👤 Fermé par", value=closed_by.mention, inline=True)
|
||||
embed.add_field(name="⏱️ Durée", value=cls._format_duration(duration_minutes), inline=True)
|
||||
embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
|
||||
|
||||
if reason:
|
||||
embed.add_field(name="📝 Raison de fermeture", value=reason, inline=False)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def ticket_reopened(cls, ticket: TicketData, reopened_by: User) -> Embed:
|
||||
"""Embed for when a ticket is reopened"""
|
||||
embed = Embed(
|
||||
title="🟢 Ticket Rouvert",
|
||||
description=f"Ce ticket a été rouvert par {reopened_by.mention}",
|
||||
color=Color.green(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.add_field(name="👤 Rouvert par", value=reopened_by.mention, inline=True)
|
||||
embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def ticket_claimed(cls, ticket: TicketData, claimed_by: User) -> Embed:
|
||||
"""Embed for when a ticket is claimed"""
|
||||
embed = Embed(
|
||||
title="🟡 Ticket Réclamé",
|
||||
description=f"Ce ticket a été réclamé par {claimed_by.mention}",
|
||||
color=Color.gold(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.add_field(name="👤 Réclamé par", value=claimed_by.mention, inline=True)
|
||||
embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def ticket_unclaimed(cls, ticket: TicketData, unclaimed_by: User) -> Embed:
|
||||
"""Embed for when a ticket claim is removed"""
|
||||
embed = Embed(
|
||||
title="🟠 Ticket Non Réclamé",
|
||||
description=f"Le ticket n'est plus réclamé par {unclaimed_by.mention}",
|
||||
color=Color.orange(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
# --- Panel Embeds ---
|
||||
|
||||
@classmethod
|
||||
def panel_embed(
|
||||
cls,
|
||||
config: GuildTicketConfig,
|
||||
guild_name: str
|
||||
) -> Embed:
|
||||
"""Embed for the ticket creation panel"""
|
||||
embed = Embed(
|
||||
title="🎫 Support - Créer un Ticket",
|
||||
description="Cliquez sur le bouton ci-dessous pour créer un ticket. Notre équipe vous répondra dans les plus brefs délais.",
|
||||
color=Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add categories
|
||||
if config.categories:
|
||||
categories_text = "\n".join(
|
||||
f"{cat.emoji} **{cat.name}**\n{cat.description}"
|
||||
for cat in config.categories
|
||||
)
|
||||
embed.add_field(name="📁 Catégories disponibles", value=categories_text, inline=False)
|
||||
|
||||
embed.set_footer(text=f"ID: {config.guild_id} | {guild_name}")
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def category_select_embed(cls, categories: List[TicketCategory]) -> Embed:
|
||||
"""Embed shown when selecting a category"""
|
||||
embed = Embed(
|
||||
title="📁 Choisir une catégorie",
|
||||
description="Sélectionnez la catégorie qui correspond le mieux à votre demande:",
|
||||
color=Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
for cat in categories:
|
||||
embed.add_field(
|
||||
name=f"{cat.emoji} {cat.name}",
|
||||
value=cat.description or "Aucune description",
|
||||
inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
# --- Admin Embeds ---
|
||||
|
||||
@classmethod
|
||||
def ticket_list_embed(
|
||||
cls,
|
||||
tickets: List[TicketData],
|
||||
user: Optional[User] = None
|
||||
) -> Embed:
|
||||
"""Embed showing a list of tickets"""
|
||||
embed = Embed(
|
||||
title="📋 Liste des Tickets",
|
||||
description=f"Nombre de tickets: **{len(tickets)}**",
|
||||
color=Color.blue()
|
||||
)
|
||||
|
||||
if user:
|
||||
embed.set_author(name=f"Tickets de {user.display_name}", icon_url=user.display_avatar.url)
|
||||
|
||||
# Group by status
|
||||
by_status = {}
|
||||
for ticket in tickets:
|
||||
status = cls.format_status(ticket.status)
|
||||
by_status.setdefault(status, []).append(ticket)
|
||||
|
||||
for status, status_tickets in by_status.items():
|
||||
ticket_list = "\n".join(
|
||||
f"`{t.channel_id}` - {t.category_id} ({cls.format_priority(t.priority)})"
|
||||
for t in status_tickets[:10] # Limit to 10 per status
|
||||
)
|
||||
if len(status_tickets) > 10:
|
||||
ticket_list += f"\n... et {len(status_tickets) - 10} autres"
|
||||
|
||||
embed.add_field(name=status, value=ticket_list or "Aucun", inline=False)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def stats_embed(cls, stats: TicketStats, guild_name: str) -> Embed:
|
||||
"""Embed showing ticket statistics"""
|
||||
embed = Embed(
|
||||
title="📊 Statistiques des Tickets",
|
||||
color=Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.set_author(name=guild_name)
|
||||
|
||||
# Main stats
|
||||
embed.add_field(name="📝 Total", value=str(stats.total_tickets), inline=True)
|
||||
embed.add_field(name="🟢 Ouverts", value=str(stats.open_tickets), inline=True)
|
||||
embed.add_field(name="🔴 Fermés", value=str(stats.closed_tickets), inline=True)
|
||||
|
||||
embed.add_field(name="⏱️ Durée Moyenne", value=cls._format_duration(stats.average_duration_minutes), inline=True)
|
||||
embed.add_field(name="⭐ Note Moyenne", value=f"{stats.average_rating:.1f}/5" if stats.average_rating else "N/A", inline=True)
|
||||
|
||||
# By category
|
||||
if stats.tickets_by_category:
|
||||
cat_text = "\n".join(f"{cat}: **{count}**" for cat, count in stats.tickets_by_category.items())
|
||||
embed.add_field(name="📁 Par Catégorie", value=cat_text, inline=False)
|
||||
|
||||
# By priority
|
||||
if stats.tickets_by_priority:
|
||||
priority_text = "\n".join(f"{prio}: **{count}**" for prio, count in stats.tickets_by_priority.items())
|
||||
embed.add_field(name="⚡ Par Priorité", value=priority_text, inline=False)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def ticket_info_embed(
|
||||
cls,
|
||||
ticket: TicketData,
|
||||
user: Optional[User] = None,
|
||||
category: Optional[TicketCategory] = None
|
||||
) -> Embed:
|
||||
"""Embed showing detailed ticket information"""
|
||||
embed = Embed(
|
||||
title=f"🎫 Informations du Ticket",
|
||||
color=cls.get_priority_color(ticket.priority),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
if user:
|
||||
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
|
||||
|
||||
embed.add_field(name="📌 ID", value=f"`{ticket.channel_id}`", inline=True)
|
||||
embed.add_field(name="📊 Statut", value=cls.format_status(ticket.status), inline=True)
|
||||
embed.add_field(name="⚡ Priorité", value=cls.format_priority(ticket.priority), inline=True)
|
||||
embed.add_field(name="📁 Catégorie", value=category.name if category else ticket.category_id, inline=True)
|
||||
|
||||
embed.add_field(name="📅 Créé le", value=ticket.created_at.strftime("%d/%m/%Y à %H:%M"), inline=True)
|
||||
|
||||
if ticket.closed_at:
|
||||
embed.add_field(name="🔴 Fermé le", value=ticket.closed_at.strftime("%d/%m/%Y à %H:%M"), inline=True)
|
||||
|
||||
if ticket.claimed_by:
|
||||
embed.add_field(name="👤 Réclamé par", value=f"<@{ticket.claimed_by}>", inline=True)
|
||||
|
||||
if ticket.reason:
|
||||
embed.add_field(name="📝 Raison", value=ticket.reason, inline=False)
|
||||
|
||||
if ticket.duration_minutes:
|
||||
embed.add_field(name="⏱️ Durée", value=cls._format_duration(ticket.duration_minutes), inline=True)
|
||||
|
||||
if ticket.rating:
|
||||
stars = "⭐" * ticket.rating
|
||||
embed.add_field(name="⭐ Note", value=f"{stars} ({ticket.rating}/5)", inline=True)
|
||||
|
||||
return embed
|
||||
|
||||
# --- Survey Embeds ---
|
||||
|
||||
@classmethod
|
||||
def survey_embed(cls) -> Embed:
|
||||
"""Embed for satisfaction survey"""
|
||||
embed = Embed(
|
||||
title="⭐ Évaluation du Support",
|
||||
description="Merci d'avoir utilisé notre système de tickets! "
|
||||
"Pour améliorer nos services, merci de noter votre expérience.",
|
||||
color=Color.gold(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.add_field(name="1 ⭐", value="Très insatisfait", inline=True)
|
||||
embed.add_field(name="3 ⭐", value="Neutre", inline=True)
|
||||
embed.add_field(name="5 ⭐", value="Très satisfait", inline=True)
|
||||
|
||||
return embed
|
||||
|
||||
@classmethod
|
||||
def survey_thanks_embed(cls, rating: int) -> Embed:
|
||||
"""Embed shown after submitting a survey"""
|
||||
stars = "⭐" * rating
|
||||
embed = Embed(
|
||||
title="Merci pour votre évaluation!",
|
||||
description=f"Vous avez attribué: {stars}",
|
||||
color=Color.green()
|
||||
)
|
||||
return embed
|
||||
|
||||
# --- Helper Methods ---
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(minutes: float) -> str:
|
||||
"""Format duration in minutes to human-readable string"""
|
||||
if minutes < 1:
|
||||
return f"{minutes * 60:.0f}s"
|
||||
elif minutes < 60:
|
||||
return f"{minutes:.0f}min"
|
||||
elif minutes < 1440:
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h"
|
||||
else:
|
||||
days = minutes / 1440
|
||||
return f"{days:.1f}j"
|
||||
|
||||
|
||||
class TicketMessageFormatter:
|
||||
"""Formats regular messages for the ticket system"""
|
||||
|
||||
@staticmethod
|
||||
def welcome_message(
|
||||
user: User,
|
||||
category: TicketCategory
|
||||
) -> str:
|
||||
"""Welcome message when entering a ticket"""
|
||||
return (
|
||||
f"👋 Bienvenue {user.mention} dans votre ticket **{category.name}**!\n\n"
|
||||
f"{category.welcome_message}\n\n"
|
||||
f"Un membre de notre équipe va vous répondre sous peu."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def staff_notification(
|
||||
claimed_by: User,
|
||||
ticket_channel_mention: str
|
||||
) -> str:
|
||||
"""Notification when a ticket is claimed"""
|
||||
return (
|
||||
f"🟡 Le ticket {ticket_channel_mention} a été réclamé par {claimed_by.mention}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ticket_closed_notification(
|
||||
closed_by: User,
|
||||
reason: Optional[str] = None
|
||||
) -> str:
|
||||
"""Notification when a ticket is closed"""
|
||||
msg = f"🔴 Ce ticket a été fermé par {closed_by.mention}"
|
||||
if reason:
|
||||
msg += f"\n📝 Raison: {reason}"
|
||||
return msg
|
||||
|
||||
@staticmethod
|
||||
def auto_close_warning(
|
||||
days_remaining: int,
|
||||
ticket_channel_mention: str
|
||||
) -> str:
|
||||
"""Warning before auto-closing a ticket"""
|
||||
return (
|
||||
f"⚠️ Ce ticket {ticket_channel_mention} sera fermé automatiquement "
|
||||
f"dans **{days_remaining} jour(s)** en raison de son inactivity."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transcript_saved(filepath: str) -> str:
|
||||
"""Message when transcript is saved"""
|
||||
return f"📄 Transcript sauvegardé: `{filepath}`"
|
||||
|
||||
@staticmethod
|
||||
def no_permission(action: str) -> str:
|
||||
"""Message when user has no permission"""
|
||||
return f"❌ Vous n'avez pas la permission de {action}"
|
||||
|
||||
@staticmethod
|
||||
def ticket_limit_reached(limit: int) -> str:
|
||||
"""Message when user has reached ticket limit"""
|
||||
return f"❌ Vous avez atteint la limite de {limit} tickets ouverts"
|
||||
|
||||
@staticmethod
|
||||
def already_has_ticket(channel_mention: str) -> str:
|
||||
"""Message when user already has a ticket"""
|
||||
return f"❌ Vous avez déjà un ticket ouvert: {channel_mention}"
|
||||
|
||||
496
commandes/ticket/utils/transcript.py
Normal file
496
commandes/ticket/utils/transcript.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"""
|
||||
Ticket Transcript Generator
|
||||
===========================
|
||||
Generates beautiful transcript files from ticket conversations.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from discord import User, Member
|
||||
|
||||
from ..data.models import TicketData, TicketMessage
|
||||
|
||||
|
||||
class TranscriptGenerator:
|
||||
"""
|
||||
Generates formatted transcripts from ticket data.
|
||||
|
||||
Features:
|
||||
- Multiple output formats (TXT, JSON, HTML)
|
||||
- Customizable templates
|
||||
- Attachment handling
|
||||
- Statistics inclusion
|
||||
"""
|
||||
|
||||
TRANSCRIPT_DIR = "data/tickets/transcripts"
|
||||
|
||||
def __init__(self):
|
||||
os.makedirs(self.TRANSCRIPT_DIR, exist_ok=True)
|
||||
|
||||
def generate_txt(
|
||||
self,
|
||||
ticket: TicketData,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
include_stats: bool = True
|
||||
) -> str:
|
||||
"""Generate a plain text transcript"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append("=" * 60)
|
||||
lines.append("TRANSCRIPT DE TICKET")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
lines.append(f"ID du salon: {ticket.channel_id}")
|
||||
lines.append(f"ID du serveur: {ticket.guild_id}")
|
||||
lines.append(f"Créé par: {ticket.user_id}")
|
||||
lines.append(f"Catégorie: {ticket.category_id}")
|
||||
lines.append(f"Statut: {ticket.status.value}")
|
||||
lines.append(f"Priorité: {ticket.priority.value}")
|
||||
lines.append(f"Créé le: {ticket.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
if ticket.closed_at:
|
||||
lines.append(f"Fermé le: {ticket.closed_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
if ticket.claimed_by:
|
||||
lines.append(f"Réclamé par: {ticket.claimed_by}")
|
||||
if ticket.reason:
|
||||
lines.append(f"Raison: {ticket.reason}")
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
lines.append("HISTORIQUE DES MESSAGES")
|
||||
lines.append("-" * 60)
|
||||
lines.append("")
|
||||
|
||||
# Messages
|
||||
if messages:
|
||||
for msg in messages:
|
||||
timestamp = msg.get("timestamp", "")
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
timestamp = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
author = msg.get("author_name", "Inconnu")
|
||||
content = msg.get("content", "")
|
||||
is_staff = msg.get("is_staff", False)
|
||||
staff_marker = " [STAFF]" if is_staff else ""
|
||||
|
||||
lines.append(f"[{timestamp}] {author}{staff_marker}")
|
||||
if content:
|
||||
lines.append(f" {content}")
|
||||
lines.append("")
|
||||
|
||||
# Statistics
|
||||
if include_stats and ticket.duration_minutes:
|
||||
lines.append("-" * 60)
|
||||
lines.append("STATISTIQUES")
|
||||
lines.append("-" * 60)
|
||||
lines.append(f"Durée: {ticket.duration_minutes:.1f} minutes")
|
||||
lines.append(f"Nombre de messages: {len(messages) if messages else len(ticket.messages)}")
|
||||
|
||||
if ticket.rating:
|
||||
stars = "⭐" * ticket.rating
|
||||
lines.append(f"Note: {stars} ({ticket.rating}/5)")
|
||||
if ticket.feedback:
|
||||
lines.append(f"Feedback: {ticket.feedback}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f"Généré le: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append("=" * 60)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_json(
|
||||
self,
|
||||
ticket: TicketData,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
include_stats: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate a JSON transcript"""
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"ticket": {
|
||||
"channel_id": ticket.channel_id,
|
||||
"guild_id": ticket.guild_id,
|
||||
"user_id": ticket.user_id,
|
||||
"category_id": ticket.category_id,
|
||||
"status": ticket.status.value,
|
||||
"priority": ticket.priority.value,
|
||||
"created_at": ticket.created_at.isoformat(),
|
||||
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
|
||||
"claimed_by": ticket.claimed_by,
|
||||
"reason": ticket.reason,
|
||||
"rating": ticket.rating,
|
||||
"feedback": ticket.feedback
|
||||
},
|
||||
"messages": messages or [m.to_dict() for m in ticket.messages]
|
||||
}
|
||||
|
||||
if include_stats:
|
||||
data["statistics"] = {
|
||||
"duration_minutes": ticket.duration_minutes,
|
||||
"message_count": len(messages) if messages else len(ticket.messages),
|
||||
"duration_formatted": self._format_duration(ticket.duration_minutes) if ticket.duration_minutes else None
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
def generate_html(
|
||||
self,
|
||||
ticket: TicketData,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
include_stats: bool = True,
|
||||
guild_name: str = "Serveur Discord"
|
||||
) -> str:
|
||||
"""Generate an HTML transcript with styling"""
|
||||
|
||||
# Get priority color
|
||||
priority_colors = {
|
||||
"low": "#2ecc71",
|
||||
"normal": "#3498db",
|
||||
"high": "#e67e22",
|
||||
"urgent": "#e74c3c"
|
||||
}
|
||||
priority_color = priority_colors.get(ticket.priority.value, "#3498db")
|
||||
|
||||
html_messages = ""
|
||||
if messages:
|
||||
for msg in messages:
|
||||
timestamp = msg.get("timestamp", "")
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
timestamp = dt.strftime("%d/%m/%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
author = msg.get("author_name", "Inconnu")
|
||||
content = msg.get("content", "").replace("\n", "<br>")
|
||||
is_staff = msg.get("is_staff", False)
|
||||
|
||||
staff_class = " staff-message" if is_staff else ""
|
||||
staff_badge = '<span class="staff-badge">STAFF</span>' if is_staff else ""
|
||||
|
||||
html_messages += f"""
|
||||
<div class="message{staff_class}">
|
||||
<div class="message-header">
|
||||
<span class="author">{author}</span>
|
||||
{staff_badge}
|
||||
<span class="timestamp">{timestamp}</span>
|
||||
</div>
|
||||
<div class="message-content">{content}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Statistics section
|
||||
stats_html = ""
|
||||
if include_stats:
|
||||
duration = self._format_duration(ticket.duration_minutes) if ticket.duration_minutes else "N/A"
|
||||
rating_stars = "⭐" * ticket.rating if ticket.rating else "Pas de note"
|
||||
|
||||
stats_html = f"""
|
||||
<div class="stats-section">
|
||||
<h3>📊 Statistiques</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Durée</span>
|
||||
<span class="stat-value">{duration}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value">{len(messages) if messages else len(ticket.messages)}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Note</span>
|
||||
<span class="stat-value">{rating_stars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Transcript - Ticket #{ticket.channel_id}</title>
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
body {{
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f5f6fa;
|
||||
color: #2c3e50;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, {priority_color}, #34495e);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.header h1 {{
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.header-meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}}
|
||||
.meta-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}}
|
||||
.status-badge {{
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.status-open {{ background: #27ae60; }}
|
||||
.status-closed {{ background: #e74c3c; }}
|
||||
.status-claimed {{ background: #f39c12; }}
|
||||
|
||||
.messages-section {{
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.messages-section h3 {{
|
||||
margin-bottom: 15px;
|
||||
color: {priority_color};
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
.message {{
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}}
|
||||
.message:last-child {{
|
||||
border-bottom: none;
|
||||
}}
|
||||
.message.staff-message {{
|
||||
background: #f8f9fa;
|
||||
border-left: 3px solid {priority_color};
|
||||
}}
|
||||
.message-header {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
.author {{
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}}
|
||||
.staff-badge {{
|
||||
background: {priority_color};
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.timestamp {{
|
||||
color: #95a5a6;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.message-content {{
|
||||
color: #34495e;
|
||||
word-break: break-word;
|
||||
}}
|
||||
|
||||
.stats-section {{
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.stats-section h3 {{
|
||||
margin-bottom: 15px;
|
||||
color: {priority_color};
|
||||
}}
|
||||
.stats-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
}}
|
||||
.stat-item {{
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-label {{
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 5px;
|
||||
}}
|
||||
.stat-value {{
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: {priority_color};
|
||||
}}
|
||||
|
||||
.footer {{
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #95a5a6;
|
||||
font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎫 Transcript de Ticket</h1>
|
||||
<div class="header-meta">
|
||||
<div class="meta-item">
|
||||
<span>📌 ID:</span>
|
||||
<span>{ticket.channel_id}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📁 Catégorie:</span>
|
||||
<span>{ticket.category_id}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>⚡ Priorité:</span>
|
||||
<span style="color: {priority_color}; font-weight: bold;">{ticket.priority.value.upper()}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📊 Statut:</span>
|
||||
<span class="status-badge status-{ticket.status.value}">{ticket.status.value}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📅 Créé:</span>
|
||||
<span>{ticket.created_at.strftime('%d/%m/%Y %H:%M')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-section">
|
||||
<h3>💬 Messages ({len(messages) if messages else len(ticket.messages)})</h3>
|
||||
{html_messages}
|
||||
</div>
|
||||
|
||||
{stats_html}
|
||||
|
||||
<div class="footer">
|
||||
<p>Généré le {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}</p>
|
||||
<p>Système de Tickets - Kuby Bot</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
return html
|
||||
|
||||
def save_transcript(
|
||||
self,
|
||||
ticket: TicketData,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
format: str = "txt",
|
||||
include_stats: bool = True
|
||||
) -> Optional[str]:
|
||||
"""Save a transcript to file and return the path"""
|
||||
|
||||
if format == "txt":
|
||||
content = self.generate_txt(ticket, messages, include_stats)
|
||||
ext = "txt"
|
||||
elif format == "json":
|
||||
content = json.dumps(self.generate_json(ticket, messages, include_stats), indent=2, ensure_ascii=False)
|
||||
ext = "json"
|
||||
elif format == "html":
|
||||
content = self.generate_html(ticket, messages, include_stats)
|
||||
ext = "html"
|
||||
else:
|
||||
return None
|
||||
|
||||
# Generate filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"ticket_{ticket.channel_id}_{timestamp}.{ext}"
|
||||
filepath = os.path.join(self.TRANSCRIPT_DIR, filename)
|
||||
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return filepath
|
||||
except Exception as e:
|
||||
print(f"[TranscriptGenerator] Error saving transcript: {e}")
|
||||
return None
|
||||
|
||||
def _format_duration(self, minutes: float) -> str:
|
||||
"""Format duration in minutes to human-readable string"""
|
||||
if minutes < 60:
|
||||
return f"{minutes:.0f} min"
|
||||
elif minutes < 1440:
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f} h"
|
||||
else:
|
||||
days = minutes / 1440
|
||||
return f"{days:.1f} j"
|
||||
|
||||
def get_transcript_path(self, channel_id: int) -> Optional[str]:
|
||||
"""Get the most recent transcript for a channel"""
|
||||
if not os.path.exists(self.TRANSCRIPT_DIR):
|
||||
return None
|
||||
|
||||
files = [f for f in os.listdir(self.TRANSCRIPT_DIR)
|
||||
if f.startswith(f"ticket_{channel_id}_")]
|
||||
|
||||
if not files:
|
||||
return None
|
||||
|
||||
latest = max(files, key=lambda x: os.path.getmtime(os.path.join(self.TRANSCRIPT_DIR, x)))
|
||||
return os.path.join(self.TRANSCRIPT_DIR, latest)
|
||||
|
||||
def cleanup_old_transcripts(self, days: int = 30) -> int:
|
||||
"""Remove transcripts older than X days"""
|
||||
if not os.path.exists(self.TRANSCRIPT_DIR):
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now().timestamp() - (days * 86400)
|
||||
removed = 0
|
||||
|
||||
for filename in os.listdir(self.TRANSCRIPT_DIR):
|
||||
filepath = os.path.join(self.TRANSCRIPT_DIR, filename)
|
||||
if os.path.getmtime(filepath) < cutoff:
|
||||
os.remove(filepath)
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_transcript_instance: Optional[TranscriptGenerator] = None
|
||||
|
||||
|
||||
def get_transcript_generator() -> TranscriptGenerator:
|
||||
"""Get the singleton transcript generator instance"""
|
||||
global _transcript_instance
|
||||
if _transcript_instance is None:
|
||||
_transcript_instance = TranscriptGenerator()
|
||||
return _transcript_instance
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue