Kuby/commandes/ticket/utils/transcript.py

497 lines
16 KiB
Python
Raw Normal View History

"""
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
2026-05-06 17:07:09 +02:00
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