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:
Mathis 2026-01-04 00:01:15 +01:00
parent 10173132a2
commit 93e2f9bb44
15 changed files with 3788 additions and 209 deletions

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

View 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}"

View 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