Kuby/commandes/ticket/utils/transcript.py

496 lines
16 KiB
Python

"""
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 disnake 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