497 lines
16 KiB
Python
497 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 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
|
||
|
|
|