feat(ticket): rewrite ticket system from scratch inspired by Kuby
All checks were successful
ci / ci (push) Successful in 46s

New minimal ticket system (dev only):
- Panel with create ticket button (Components V2, no accent colors)
- Category selection via dropdown or direct modal (1 cat)
- Ticket channel creation with permissions, staff role, welcome message
- Close flow: staff check, transcript HTML (dark theme), log, auto-delete
- /ticket setup, /ticket config (interactive panel)
- Storage: JSON per-ticket + config.json (singleton pattern)
- Modular architecture: data.py, storage.py, ui.py, transcript.py, cog.py
- Removed old modmail system
This commit is contained in:
Mathis 2026-09-20 20:04:10 +02:00
parent 4d2be145aa
commit 57b6f09dd7
10 changed files with 905 additions and 943 deletions

2
bot.py
View file

@ -26,7 +26,7 @@ class VisionBot(commands.Bot):
async def setup_hook(self):
logger.info("Chargement des cogs...")
await self.load_extension("commandes.hello")
await self.load_extension("commandes.modmail")
await self.load_extension("tickets.cog")
self.tree.on_error = self.on_app_command_error

View file

@ -1,612 +0,0 @@
import contextlib
import discord
from discord import app_commands
from discord.ext import commands
from utils import modmail_config as cfg
from utils.modmail_transcript import generate_transcript, save_transcript
class CategorySelect(discord.ui.Select):
def __init__(self, categories: dict):
options = []
for key, cat in categories.items():
options.append(
discord.SelectOption(
label=cat["name"],
value=key,
emoji=cat.get("emoji", ""),
description=cat.get("description", "")[:100],
)
)
super().__init__(
placeholder="Selectionne une categorie...",
options=options,
custom_id="modmail_category_select",
)
async def callback(self, interaction: discord.Interaction):
category_key = self.values[0]
categories = cfg.get_categories()
cat = categories.get(category_key, {})
data = cfg.get_config()
guild = interaction.client.get_guild(data.get("guild_id"))
if not guild:
await interaction.response.send_message("Erreur: serveur introuvable.", ephemeral=True)
return
user = interaction.user
existing = cfg.get_ticket_by_user(user.id)
if existing:
ch = guild.get_channel(existing["channel_id"])
await interaction.response.send_message(
f"Tu as deja un ticket ouvert: {ch.mention if ch else 'inconnu'}",
ephemeral=True,
)
return
await interaction.response.send_modal(TicketModal(category_key, cat.get("name", category_key)))
class TicketModal(discord.ui.Modal, title="Nouveau Ticket"):
reason = discord.ui.TextInput(
label="Decris ton probleme",
style=discord.TextStyle.paragraph,
placeholder="Explique brievement ta demande...",
required=True,
max_length=2000,
)
def __init__(self, category_key: str, category_name: str):
super().__init__()
self.category_key = category_key
self.category_name = category_name
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
bot = interaction.client
data = cfg.get_config()
guild = bot.get_guild(data.get("guild_id"))
if not guild:
await interaction.followup.send("Erreur serveur.", ephemeral=True)
return
category = guild.get_channel(data["category_id"]) if data.get("category_id") else None
staff_role = guild.get_role(data["staff_role_id"]) if data.get("staff_role_id") else None
overwrites = {
guild.default_role: discord.PermissionOverwrite(view_channel=False),
guild.me: discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
attach_files=True,
manage_channels=True,
),
}
if staff_role:
overwrites[staff_role] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
attach_files=True,
)
ticket_name = f"ticket-{interaction.user.name}"
ticket_channel = await guild.create_text_channel(
ticket_name,
category=category,
overwrites=overwrites,
topic=f"Ticket de {interaction.user} ({interaction.user.id}) -- {self.category_name}",
)
cfg.register_ticket(guild.id, interaction.user.id, ticket_channel.id, self.category_key)
await _send_ticket_panel(ticket_channel, interaction.user, self.category_name, self.reason.value)
cog = interaction.client.get_cog("ModMail")
if cog is not None:
cog._dm_messages.setdefault(interaction.user.id, []).append(
{
"author": interaction.user,
"content": self.reason.value,
"timestamp": discord.utils.utcnow(),
"attachments": [],
"is_staff": False,
}
)
with contextlib.suppress(discord.HTTPException):
await interaction.followup.send(
f"Ticket cree: {ticket_channel.mention}",
ephemeral=True,
)
log_ch = guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None
if log_ch:
await log_ch.send(
f"Ticket cree par {interaction.user.mention} dans {ticket_channel.mention} ({self.category_name})"
)
async def _send_ticket_panel(channel, user, category_name, initial_message):
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
container.add_item(
discord.ui.TextDisplay(
f"## Ticket — {category_name}\n"
f"**Utilisateur:** {user.mention} (`{user.id}`)\n"
f"**Categorie:** {category_name}"
)
)
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.TextDisplay(f"**Message initial:**\n{initial_message}"))
container.add_item(discord.ui.Separator())
close_button = discord.ui.Button(
label="Fermer le ticket",
style=discord.ButtonStyle.danger,
custom_id="modmail_close_ticket",
)
container.add_item(discord.ui.ActionRow(close_button))
view = discord.ui.LayoutView()
view.add_item(container)
await channel.send(view=view)
class PersistentCloseButton(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="Fermer le ticket",
style=discord.ButtonStyle.danger,
custom_id="modmail_close_ticket",
)
async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button):
cog = interaction.client.get_cog("ModMail")
if cog:
await cog._close_ticket(interaction.channel, interaction.user)
class PersistentQuickOpen(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(
label="Ouvrir le ticket",
style=discord.ButtonStyle.primary,
custom_id="modmail_quick_open",
)
async def quick_open(self, interaction: discord.Interaction, button: discord.ui.Button):
categories = cfg.get_categories()
if len(categories) == 1:
key = list(categories.keys())[0]
cat = categories[key]
await interaction.response.send_modal(TicketModal(key, cat.get("name", key)))
else:
await interaction.response.send_message("Erreur: reconfigurer le modmail.", ephemeral=True)
class TicketCategorySelect(discord.ui.ChannelSelect):
def __init__(self):
super().__init__(
placeholder="Categorie des tickets",
channel_types=[discord.ChannelType.category],
custom_id="modmail_cfg_ticket_category",
)
async def callback(self, interaction: discord.Interaction):
cfg.set_category(interaction.guild_id, self.values[0].id)
await interaction.response.edit_message(view=ModmailConfigView(interaction.guild))
class StaffRoleSelect(discord.ui.RoleSelect):
def __init__(self):
super().__init__(
placeholder="Role du staff",
custom_id="modmail_cfg_staff_role",
)
async def callback(self, interaction: discord.Interaction):
cfg.set_staff_role(interaction.guild_id, self.values[0].id)
await interaction.response.edit_message(view=ModmailConfigView(interaction.guild))
class LogChannelSelect(discord.ui.ChannelSelect):
def __init__(self):
super().__init__(
placeholder="Canal de logs",
channel_types=[discord.ChannelType.text],
custom_id="modmail_cfg_log_channel",
)
async def callback(self, interaction: discord.Interaction):
cfg.set_log_channel(interaction.guild_id, self.values[0].id)
await interaction.response.edit_message(view=ModmailConfigView(interaction.guild))
class ToggleButton(discord.ui.Button):
def __init__(self, enabled: bool):
super().__init__(
label="Desactiver" if enabled else "Activer",
style=discord.ButtonStyle.danger if enabled else discord.ButtonStyle.success,
custom_id="modmail_cfg_toggle",
)
async def callback(self, interaction: discord.Interaction):
data = cfg.get_config()
cfg.set_enabled(interaction.guild_id, not data.get("enabled", False))
await interaction.response.edit_message(view=ModmailConfigView(interaction.guild))
class ManageCategoriesButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Gerer les categories",
style=discord.ButtonStyle.primary,
custom_id="modmail_cfg_manage_cat",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild))
class CategoryRemoveSelect(discord.ui.Select):
def __init__(self, categories: dict):
options = [
discord.SelectOption(
label=v["name"],
value=k,
emoji=v.get("emoji") or None,
description=v.get("description", "")[:100] or None,
)
for k, v in list(categories.items())[:25]
]
super().__init__(
placeholder="Supprimer une categorie",
options=options,
custom_id="modmail_cfg_remove_cat",
)
async def callback(self, interaction: discord.Interaction):
cfg.remove_category(interaction.guild_id, self.values[0])
await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild))
class AddCategoryButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Ajouter une categorie",
style=discord.ButtonStyle.success,
custom_id="modmail_cfg_add_cat",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.send_modal(AddCategoryModal())
class BackButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Retour",
style=discord.ButtonStyle.secondary,
custom_id="modmail_cfg_back",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.edit_message(view=ModmailConfigView(interaction.guild))
class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"):
name = discord.ui.TextInput(label="Nom", max_length=50, required=True)
emoji = discord.ui.TextInput(label="Emoji", max_length=50, required=False)
description = discord.ui.TextInput(
label="Description",
max_length=100,
required=False,
style=discord.TextStyle.paragraph,
)
async def on_submit(self, interaction: discord.Interaction):
cfg.add_category(
interaction.guild_id,
self.name.value,
self.emoji.value or "🎫",
self.description.value or "",
)
await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild))
def _fmt_channel(cid) -> str:
return f"<#{cid}>" if cid else "`non defini`"
def _fmt_role(rid) -> str:
return f"<@&{rid}>" if rid else "`non defini`"
class ModmailConfigView(discord.ui.LayoutView):
def __init__(self, guild: discord.Guild):
super().__init__(timeout=None)
data = cfg.get_config()
categories = cfg.get_categories()
enabled = data.get("enabled", False)
cat_list = (
"\n".join(f"- {v.get('emoji', '')} **{v['name']}**" for v in categories.values()) or "*Aucune categorie*"
)
container = discord.ui.Container(
accent_colour=discord.Colour.green() if enabled else discord.Colour.red(),
)
container.add_item(
discord.ui.TextDisplay(
f"## Configuration Modmail\n"
f"**Statut :** {'🟢 Actif' if enabled else '🔴 Inactif'}\n"
f"**Categorie des tickets :** {_fmt_channel(data.get('category_id'))}\n"
f"**Role staff :** {_fmt_role(data.get('staff_role_id'))}\n"
f"**Canal de logs :** {_fmt_channel(data.get('log_channel_id'))}\n"
f"### Categories ({len(categories)})\n{cat_list}"
)
)
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.ActionRow(TicketCategorySelect()))
container.add_item(discord.ui.ActionRow(StaffRoleSelect()))
container.add_item(discord.ui.ActionRow(LogChannelSelect()))
container.add_item(discord.ui.ActionRow(ToggleButton(enabled), ManageCategoriesButton()))
self.add_item(container)
class ModmailCategoriesView(discord.ui.LayoutView):
def __init__(self, guild: discord.Guild):
super().__init__(timeout=None)
categories = cfg.get_categories()
cat_list = (
"\n".join(
f"- {v.get('emoji', '')} **{v['name']}**" + (f" — {v['description']}" if v.get("description") else "")
for v in categories.values()
)
or "*Aucune categorie*"
)
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
container.add_item(
discord.ui.TextDisplay(
f"## Gestion des categories\n"
f"{cat_list}\n\n"
"*Selectionne une categorie pour la supprimer, ou ajoute-en une nouvelle.*"
)
)
container.add_item(discord.ui.Separator())
if categories:
container.add_item(discord.ui.ActionRow(CategoryRemoveSelect(categories)))
container.add_item(discord.ui.ActionRow(AddCategoryButton(), BackButton()))
self.add_item(container)
class ModMail(commands.Cog):
"""Systeme de modmail / tickets via DM."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self._dm_messages: dict[int, list[dict]] = {}
self.bot.add_view(PersistentCloseButton())
self.bot.add_view(PersistentQuickOpen())
# --- LISTENERS ---
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot:
return
if isinstance(message.channel, discord.DMChannel):
await self._handle_dm(message)
return
await self._relay_to_dm(message)
await self.bot.process_commands(message)
async def _handle_dm(self, message: discord.Message):
data = cfg.get_config()
if not data.get("enabled"):
return
if not data.get("guild_id"):
return
existing = cfg.get_ticket_by_user(message.author.id)
if existing:
guild = self.bot.get_guild(existing["guild_id"])
if guild:
ch = guild.get_channel(existing["channel_id"])
if ch:
await self._send_to_ticket(ch, message.author, message)
return
await self._send_ticket_creation_panel(message)
async def _send_ticket_creation_panel(self, message: discord.Message):
categories = cfg.get_categories()
if not categories:
await message.author.send("Aucune categorie de ticket configuree.")
return
if len(categories) == 1:
key = list(categories.keys())[0]
cat = categories[key]
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
container.add_item(
discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.")
)
open_button = discord.ui.Button(
label=f"Ouvrir — {cat['name']}",
style=discord.ButtonStyle.primary,
custom_id=f"modmail_quick_open:{key}",
)
container.add_item(discord.ui.ActionRow(open_button))
view = discord.ui.LayoutView()
view.add_item(container)
await message.author.send(view=view)
return
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
container.add_item(
discord.ui.TextDisplay("## Ouvrir un ticket\nChoisis une categorie pour ouvrir un ticket avec le staff.")
)
container.add_item(discord.ui.Separator())
container.add_item(CategorySelect(categories))
view = discord.ui.LayoutView()
view.add_item(container)
await message.author.send(view=view)
async def _relay_to_dm(self, message: discord.Message):
ticket = cfg.get_ticket_by_channel(message.channel.id)
if not ticket:
return
user_id = ticket["user_id"]
user = self.bot.get_user(user_id)
if not user:
return
if not self._dm_messages.get(user_id):
self._dm_messages[user_id] = []
self._dm_messages[user_id].append(
{
"author": message.author,
"content": message.content,
"timestamp": message.created_at,
"attachments": list(message.attachments),
"is_staff": True,
}
)
with contextlib.suppress(discord.HTTPException):
embed = discord.Embed(
description=message.content or "*Pieces jointes*",
color=discord.Color.green(),
timestamp=message.created_at,
)
embed.set_author(
name=str(message.author),
icon_url=message.author.display_avatar.url,
)
embed.set_footer(text=f"Staff — {message.guild.name}" if message.guild else "Staff")
await user.send(embed=embed)
for att in message.attachments:
with contextlib.suppress(discord.HTTPException):
await user.send(file=await att.to_file())
async def _send_to_ticket(self, channel, user, message):
if not self._dm_messages.get(user.id):
self._dm_messages[user.id] = []
self._dm_messages[user.id].append(
{
"author": user,
"content": message.content,
"timestamp": message.created_at,
"attachments": list(message.attachments),
"is_staff": False,
}
)
embed = discord.Embed(
description=message.content or "*Pieces jointes*",
color=discord.Color.blurple(),
timestamp=message.created_at,
)
embed.set_author(
name=str(user),
icon_url=user.display_avatar.url,
)
embed.set_footer(text=f"User ID: {user.id}")
await channel.send(embed=embed)
for att in message.attachments:
await channel.send(file=await att.to_file())
# --- COMMANDES ---
modmail_group = app_commands.Group(name="modmail", description="Systeme de modmail")
@modmail_group.command(name="config", description="Panneau de configuration du modmail")
@app_commands.checks.has_permissions(manage_guild=True)
async def config_cmd(self, interaction: discord.Interaction):
cfg.set_guild_id(interaction.guild_id)
await interaction.response.send_message(
view=ModmailConfigView(interaction.guild),
ephemeral=True,
)
# --- CLOSE ---
@commands.command(name="close")
async def close_prefix(self, ctx: commands.Context):
await self._close_ticket(ctx.channel, ctx.author)
@app_commands.command(name="close", description="Fermer le ticket actuel")
async def close_slash(self, interaction: discord.Interaction):
await self._close_ticket(interaction.channel, interaction.user)
async def _close_ticket(self, channel, user):
ticket = cfg.get_ticket_by_channel(channel.id)
if not ticket:
await channel.send("Ce n'est pas un ticket.")
return
user_id = ticket["user_id"]
category = ticket.get("category", "inconnu")
dm_messages = self._dm_messages.get(user_id, [])
transcript_html = generate_transcript(
dm_messages,
{
"category": category,
"user_id": user_id,
"channel_id": channel.id,
},
)
transcript_path = save_transcript(transcript_html, channel.id)
dm_user = self.bot.get_user(user_id)
if dm_user:
with contextlib.suppress(discord.HTTPException):
await dm_user.send(
"Ton ticket a ete ferme. Voici le transcript:",
file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"),
)
data = cfg.get_config()
log_ch = channel.guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None
if log_ch:
await log_ch.send(
f"Ticket ferme par {user.mention} — {channel.name}",
file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"),
)
cfg.unregister_ticket(user_id)
self._dm_messages.pop(user_id, None)
container = discord.ui.Container(accent_colour=discord.Colour.red())
container.add_item(
discord.ui.TextDisplay(f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}")
)
view = discord.ui.LayoutView()
view.add_item(container)
await channel.send(view=view)
with contextlib.suppress(discord.HTTPException):
await channel.delete(reason=f"Ticket ferme par {user}")
async def setup(bot: commands.Bot):
await bot.add_cog(ModMail(bot))

0
tickets/__init__.py Normal file
View file

241
tickets/cog.py Normal file
View file

@ -0,0 +1,241 @@
from __future__ import annotations
import logging
import discord
from discord import app_commands
from discord.ext import commands
from tickets import storage
from tickets.data import TicketCategory
from tickets.ui import PanelCreateButton
logger = logging.getLogger("vision")
class TicketSystem(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.bot.add_view(PanelView())
@commands.Cog.listener()
async def on_ready(self):
logger.info("TicketSystem cog charge")
class PanelView(discord.ui.LayoutView):
def __init__(self):
super().__init__(timeout=None)
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
"## Systeme de tickets\n"
"Besoin d'aide ? Creez un ticket et notre equipe vous repondra.\n"
"Cliquez sur le bouton ci-dessous pour commencer."
)
)
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.ActionRow(PanelCreateButton()))
self.add_item(container)
ticket_group = app_commands.Group(name="ticket", description="Systeme de tickets")
@ticket_group.command(name="setup", description="Envoyer le panneau de tickets")
@app_commands.describe(channel="Canal pour le panneau")
@app_commands.checks.has_permissions(manage_guild=True)
async def ticket_setup(interaction: discord.Interaction, channel: discord.TextChannel):
config = storage.get_config(interaction.guild_id)
config.enabled = True
config.panel_channel_id = channel.id
storage.save_config(config)
view = PanelView()
msg = await channel.send(view=view)
config.panel_message_id = msg.id
storage.save_config(config)
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay(f"## Panneau envoye\nCanal : {channel.mention}\nStatut : Actif"))
resp_view = discord.ui.LayoutView()
resp_view.add_item(container)
await interaction.response.send_message(view=resp_view, ephemeral=True)
@ticket_group.command(name="config", description="Configuration du systeme de tickets")
@app_commands.checks.has_permissions(manage_guild=True)
async def ticket_config(interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
cats = "\n".join(f"- {c.emoji} **{c.name}**" for c in config.categories) or "*Aucune*"
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
f"## Configuration Tickets\n"
f"**Statut :** {'🟢 Actif' if config.enabled else '🔴 Inactif'}\n"
f"**Role staff :** <@&{config.staff_role_id}>\n"
if config.staff_role_id
else f"**Canal de logs :** <#{config.log_channel_id}>\n"
if config.log_channel_id
else f"### Categories\n{cats}"
)
)
container.add_item(discord.ui.Separator())
container.add_item(
discord.ui.ActionRow(
ConfigToggleButton(config.enabled),
ConfigAddCatButton(),
ConfigSetStaffButton(),
ConfigSetLogButton(),
)
)
view = discord.ui.LayoutView()
view.add_item(container)
await interaction.response.send_message(view=view, ephemeral=True)
class ConfigToggleButton(discord.ui.Button):
def __init__(self, enabled: bool):
super().__init__(
label="Desactiver" if enabled else "Activer",
style=discord.ButtonStyle.danger if enabled else discord.ButtonStyle.success,
custom_id="ticket_cfg_toggle",
)
async def callback(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
config.enabled = not config.enabled
storage.save_config(config)
await interaction.response.edit_message(view=ConfigView())
class ConfigAddCatButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Ajouter categorie",
style=discord.ButtonStyle.primary,
custom_id="ticket_cfg_addcat",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.send_modal(AddCategoryModal())
class ConfigSetStaffButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Role staff",
style=discord.ButtonStyle.secondary,
custom_id="ticket_cfg_staff",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.send_message(
"Selectionnez le role staff :",
view=StaffRoleSelectView(),
ephemeral=True,
)
class ConfigSetLogButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Canal logs",
style=discord.ButtonStyle.secondary,
custom_id="ticket_cfg_log",
)
async def callback(self, interaction: discord.Interaction):
await interaction.response.send_message(
"Selectionnez le canal de logs :",
view=LogChannelSelectView(),
ephemeral=True,
)
class ConfigView(discord.ui.LayoutView):
def __init__(self):
super().__init__(timeout=None)
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay("## Configuration Tickets\nModifications enregistrees."))
self.add_item(container)
class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"):
name = discord.ui.TextInput(label="Nom", max_length=50, required=True)
emoji = discord.ui.TextInput(label="Emoji", max_length=10, required=False)
description = discord.ui.TextInput(
label="Description",
max_length=100,
required=False,
style=discord.TextStyle.paragraph,
)
async def on_submit(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
cat_id = self.name.value.lower().replace(" ", "-")[:20]
cat = TicketCategory(
id=cat_id,
name=self.name.value,
emoji=self.emoji.value or "",
description=self.description.value or "",
)
config.categories.append(cat)
storage.save_config(config)
await interaction.response.send_message(f"Categorie **{cat.name}** ajoutee.", ephemeral=True)
class StaffRoleSelect(discord.ui.RoleSelect):
def __init__(self):
super().__init__(
placeholder="Role staff...",
custom_id="ticket_staff_role_select",
min_values=1,
max_values=1,
)
async def callback(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
config.staff_role_id = self.values[0].id
storage.save_config(config)
await interaction.response.edit_message(
content=f"Role staff defini : {self.values[0].mention}",
view=None,
)
class StaffRoleSelectView(discord.ui.View):
def __init__(self):
super().__init__(timeout=300)
self.add_item(StaffRoleSelect())
class LogChannelSelect(discord.ui.ChannelSelect):
def __init__(self):
super().__init__(
placeholder="Canal de logs...",
custom_id="ticket_log_channel_select",
channel_types=[discord.ChannelType.text, discord.ChannelType.public_thread],
min_values=1,
max_values=1,
)
async def callback(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
config.log_channel_id = self.values[0].id
storage.save_config(config)
await interaction.response.edit_message(
content=f"Canal de logs defini : {self.values[0].mention}",
view=None,
)
class LogChannelSelectView(discord.ui.View):
def __init__(self):
super().__init__(timeout=300)
self.add_item(LogChannelSelect())
async def setup(bot: commands.Bot):
await bot.add_cog(TicketSystem(bot))

119
tickets/data.py Normal file
View file

@ -0,0 +1,119 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class TicketStatus(str, Enum):
OPEN = "open"
CLOSED = "closed"
@dataclass
class TicketCategory:
id: str
name: str
emoji: str = ""
description: str = ""
staff_role_id: int | None = None
discord_category_id: int | None = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"emoji": self.emoji,
"description": self.description,
"staff_role_id": self.staff_role_id,
"discord_category_id": self.discord_category_id,
}
@classmethod
def from_dict(cls, data: dict) -> TicketCategory:
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class TicketMessage:
author_id: int
author_name: str
content: str
timestamp: str
attachments: list[str] = field(default_factory=list)
is_staff: bool = False
@dataclass
class TicketData:
channel_id: int
guild_id: int
user_id: int
category_id: str
status: TicketStatus = TicketStatus.OPEN
created_at: str = ""
closed_at: str | None = None
closed_by: int | None = None
messages: list[TicketMessage] = field(default_factory=list)
reason: str = ""
def to_dict(self) -> dict:
return {
"channel_id": self.channel_id,
"guild_id": self.guild_id,
"user_id": self.user_id,
"category_id": self.category_id,
"status": self.status.value,
"created_at": self.created_at,
"closed_at": self.closed_at,
"closed_by": self.closed_by,
"messages": [
{
"author_id": m.author_id,
"author_name": m.author_name,
"content": m.content,
"timestamp": m.timestamp,
"attachments": m.attachments,
"is_staff": m.is_staff,
}
for m in self.messages
],
"reason": self.reason,
}
@classmethod
def from_dict(cls, data: dict) -> TicketData:
messages = [TicketMessage(**m) for m in data.pop("messages", [])]
data["status"] = TicketStatus(data.get("status", "open"))
data.pop("transcript_path", None)
data.pop("rating", None)
data.pop("feedback", None)
return cls(messages=messages, **{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class GuildTicketConfig:
guild_id: int
enabled: bool = False
categories: list[TicketCategory] = field(default_factory=list)
staff_role_id: int | None = None
log_channel_id: int | None = None
panel_channel_id: int | None = None
panel_message_id: int | None = None
transcript_enabled: bool = True
def to_dict(self) -> dict:
return {
"guild_id": self.guild_id,
"enabled": self.enabled,
"categories": [c.to_dict() for c in self.categories],
"staff_role_id": self.staff_role_id,
"log_channel_id": self.log_channel_id,
"panel_channel_id": self.panel_channel_id,
"panel_message_id": self.panel_message_id,
"transcript_enabled": self.transcript_enabled,
}
@classmethod
def from_dict(cls, data: dict) -> GuildTicketConfig:
categories = [TicketCategory.from_dict(c) for c in data.pop("categories", [])]
return cls(categories=categories, **{k: v for k, v in data.items() if k in cls.__dataclass_fields__})

86
tickets/storage.py Normal file
View file

@ -0,0 +1,86 @@
from __future__ import annotations
import json
import logging
import threading
from config import DATA_DIR
from tickets.data import GuildTicketConfig, TicketData
logger = logging.getLogger("vision")
TICKETS_DIR = DATA_DIR / "tickets"
_CONFIG_FILE = TICKETS_DIR / "config.json"
_lock = threading.Lock()
_config_cache: dict[int, GuildTicketConfig] = {}
_tickets_cache: dict[int, TicketData] = {}
def _ensure_dirs():
TICKETS_DIR.mkdir(parents=True, exist_ok=True)
def _load_all_configs() -> dict[int, GuildTicketConfig]:
global _config_cache
_ensure_dirs()
if _config_cache:
return _config_cache
if _CONFIG_FILE.exists():
with open(_CONFIG_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
_config_cache = {int(k): GuildTicketConfig.from_dict(v) for k, v in raw.items()}
else:
_config_cache = {}
return _config_cache
def _save_all_configs():
_ensure_dirs()
data = {str(k): v.to_dict() for k, v in _config_cache.items()}
with open(_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_config(guild_id: int) -> GuildTicketConfig:
with _lock:
configs = _load_all_configs()
if guild_id not in configs:
configs[guild_id] = GuildTicketConfig(guild_id=guild_id)
return configs[guild_id]
def save_config(config: GuildTicketConfig):
with _lock:
_config_cache[config.guild_id] = config
_save_all_configs()
def get_ticket(channel_id: int) -> TicketData | None:
with _lock:
if channel_id in _tickets_cache:
return _tickets_cache[channel_id]
ticket_file = TICKETS_DIR / f"{channel_id}.json"
if ticket_file.exists():
with open(ticket_file, "r", encoding="utf-8") as f:
ticket = TicketData.from_dict(json.load(f))
_tickets_cache[channel_id] = ticket
return ticket
return None
def save_ticket(ticket: TicketData):
with _lock:
_tickets_cache[ticket.channel_id] = ticket
_ensure_dirs()
ticket_file = TICKETS_DIR / f"{ticket.channel_id}.json"
with open(ticket_file, "w", encoding="utf-8") as f:
json.dump(ticket.to_dict(), f, indent=2, ensure_ascii=False)
def delete_ticket(channel_id: int):
with _lock:
_tickets_cache.pop(channel_id, None)
ticket_file = TICKETS_DIR / f"{channel_id}.json"
if ticket_file.exists():
ticket_file.unlink()

174
tickets/transcript.py Normal file
View file

@ -0,0 +1,174 @@
from __future__ import annotations
import html
from datetime import datetime, timezone
from tickets.data import TicketData
def _format_ts(dt_str: str) -> str:
try:
dt = datetime.fromisoformat(dt_str)
return dt.strftime("%d/%m/%Y %H:%M:%S")
except (ValueError, TypeError):
return dt_str
def generate_transcript(ticket: TicketData, guild_name: str = "") -> str:
now = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S")
messages_html = ""
for msg in ticket.messages:
content = html.escape(msg.content) if msg.content else ""
ts = _format_ts(msg.timestamp)
badge = '<span class="badge badge-staff">STAFF</span>' if msg.is_staff else ""
att_html = ""
for att in msg.get("attachments", []) if hasattr(msg, "attachments") else msg.attachments:
if any(att.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp")):
att_html += f'<div class="attachment"><img src="{html.escape(att)}" /></div>'
else:
att_html += f'<div class="attachment"><a href="{html.escape(att)}">{html.escape(att.rsplit("/", 1)[-1])}</a></div>'
messages_html += f"""
<div class="message {"staff" if msg.is_staff else "user"}">
<div class="msg-header">
<span class="author">{html.escape(msg.author_name)}</span>
{badge}
<span class="timestamp">{ts}</span>
</div>
<div class="msg-content">{content}</div>
{att_html}
</div>
"""
if not messages_html:
messages_html = '<div class="empty">Aucun message.</div>'
return 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', system-ui, -apple-system, sans-serif;
background: #0b0d11;
color: #dcddde;
padding: 20px;
}}
.header {{
background: #1a1d23;
border: 1px solid #2f3136;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
text-align: center;
}}
.header h1 {{
font-size: 22px;
color: #fff;
margin-bottom: 8px;
}}
.header .meta {{
font-size: 13px;
color: #72767d;
}}
.header .meta span {{
background: #2f3136;
padding: 2px 10px;
border-radius: 20px;
margin: 0 4px;
}}
.messages {{
max-width: 800px;
margin: 0 auto;
}}
.message {{
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
}}
.message:hover {{
background: rgba(255,255,255,0.02);
}}
.message.staff {{
border-left: 3px solid #57f287;
}}
.message.user {{
border-left: 3px solid #5865f2;
}}
.msg-header {{
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}}
.author {{
font-weight: 600;
font-size: 14px;
color: #fff;
}}
.badge {{
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}}
.badge-staff {{ background: #57f287; color: #000; }}
.timestamp {{
font-size: 11px;
color: #4f545c;
margin-left: auto;
}}
.msg-content {{
font-size: 14px;
line-height: 1.5;
color: #dcddde;
white-space: pre-wrap;
word-break: break-word;
margin-left: 0;
}}
.attachment {{
margin-top: 6px;
}}
.attachment img {{
max-width: 400px;
max-height: 300px;
border-radius: 8px;
border: 1px solid #2f3136;
}}
.attachment a {{ color: #00aff4; text-decoration: none; }}
.empty {{
text-align: center;
color: #72767d;
padding: 40px;
}}
.footer {{
text-align: center;
color: #4f545c;
font-size: 11px;
margin-top: 32px;
padding: 16px;
border-top: 1px solid #2f3136;
}}
</style>
</head>
<body>
<div class="header">
<h1>Ticket #{ticket.channel_id}</h1>
<div class="meta">
<span>Categorie: {html.escape(ticket.category_id)}</span>
<span>User: {ticket.user_id}</span>
<span>Cree: {_format_ts(ticket.created_at)}</span>
{f"<span>Ferme: {_format_ts(ticket.closed_at)}</span>" if ticket.closed_at else ""}
<span>Genere: {now}</span>
</div>
</div>
<div class="messages">{messages_html}</div>
<div class="footer">Vision Studio — Transcript</div>
</body>
</html>"""

284
tickets/ui.py Normal file
View file

@ -0,0 +1,284 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import datetime, timezone
import discord
from tickets import storage
from tickets.data import TicketCategory, TicketData, TicketStatus
from tickets.transcript import generate_transcript
logger = logging.getLogger("vision")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _is_staff(member: discord.Member, config) -> bool:
if member.guild_permissions.administrator:
return True
if config.staff_role_id and config.staff_role_id in [r.id for r in member.roles]:
return True
return any(cat.staff_role_id and cat.staff_role_id in [r.id for r in member.roles] for cat in config.categories)
async def _send_log(guild: discord.Guild, config, embed: discord.Embed):
if config.log_channel_id:
ch = guild.get_channel(config.log_channel_id)
if ch:
with contextlib.suppress(discord.HTTPException):
await ch.send(embed=embed)
class PanelCreateButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Creer un ticket",
style=discord.ButtonStyle.primary,
custom_id="ticket_panel_create",
)
async def callback(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
if not config.enabled:
await interaction.response.send_message("Le systeme de tickets est desactive.", ephemeral=True)
return
categories = config.categories
if not categories:
await interaction.response.send_message("Aucune categorie configuree.", ephemeral=True)
return
if len(categories) == 1:
cat = categories[0]
await interaction.response.send_modal(TicketModal(cat.id, cat.name))
return
await interaction.response.send_message(
"Choisissez une categorie :",
view=CategorySelectView(categories),
ephemeral=True,
)
class CategorySelect(discord.ui.Select):
def __init__(self, categories: list[TicketCategory]):
options = [
discord.SelectOption(
label=c.name,
value=c.id,
emoji=c.emoji or None,
description=c.description[:100] if c.description else None,
)
for c in categories[:25]
]
super().__init__(
placeholder="Selectionnez une categorie...",
options=options,
custom_id="ticket_category_select",
)
async def callback(self, interaction: discord.Interaction):
cat_id = self.values[0]
config = storage.get_config(interaction.guild_id)
cat = next((c for c in config.categories if c.id == cat_id), None)
if not cat:
await interaction.response.send_message("Categorie introuvable.", ephemeral=True)
return
await interaction.response.send_modal(TicketModal(cat.id, cat.name))
class CategorySelectView(discord.ui.View):
def __init__(self, categories: list[TicketCategory]):
super().__init__(timeout=300)
self.add_item(CategorySelect(categories))
class TicketModal(discord.ui.Modal, title="Nouveau Ticket"):
reason = discord.ui.TextInput(
label="Decris ton probleme",
style=discord.TextStyle.paragraph,
placeholder="Explique brievement ta demande...",
required=True,
max_length=2000,
)
def __init__(self, category_id: str, category_name: str):
super().__init__()
self.category_id = category_id
self.category_name = category_name
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
config = storage.get_config(interaction.guild_id)
guild = interaction.guild
cat = next((c for c in config.categories if c.id == self.category_id), None)
if not cat:
await interaction.followup.send("Categorie introuvable.", ephemeral=True)
return
overwrites = {
guild.default_role: discord.PermissionOverwrite(view_channel=False),
guild.me: discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
attach_files=True,
manage_channels=True,
),
interaction.user: discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
),
}
if config.staff_role_id:
staff_role = guild.get_role(config.staff_role_id)
if staff_role:
overwrites[staff_role] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
attach_files=True,
)
if cat.staff_role_id:
cat_role = guild.get_role(cat.staff_role_id)
if cat_role:
overwrites[cat_role] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
attach_files=True,
)
discord_cat = guild.get_channel(cat.discord_category_id) if cat.discord_category_id else None
safe_name = interaction.user.name.lower().replace(" ", "-")[:20]
channel = await guild.create_text_channel(
f"ticket-{safe_name}",
category=discord_cat,
overwrites=overwrites,
topic=f"Ticket de {interaction.user} | {cat.name}",
)
ticket = TicketData(
channel_id=channel.id,
guild_id=guild.id,
user_id=interaction.user.id,
category_id=cat.id,
created_at=_now_iso(),
reason=self.reason.value,
)
storage.save_ticket(ticket)
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
f"## Ticket — {cat.name}\n"
f"**Utilisateur :** {interaction.user.mention}\n"
f"**Categorie :** {cat.emoji} {cat.name}\n"
f"**Raison :**\n{self.reason.value}"
)
)
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.ActionRow(CloseButton()))
view = discord.ui.LayoutView()
view.add_item(container)
mention = interaction.user.mention
if config.staff_role_id:
mention += f" <@&{config.staff_role_id}>"
if cat.staff_role_id:
mention += f" <@&{cat.staff_role_id}>"
await channel.send(content=mention, view=view)
await interaction.followup.send(f"Ticket cree : {channel.mention}", ephemeral=True)
embed = discord.Embed(
title="Ticket cree",
description=f"**Categorie :** {cat.name}\n**Utilisateur :** {interaction.user.mention}\n**Canal :** {channel.mention}",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(guild, config, embed)
class CloseButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Fermer le ticket",
style=discord.ButtonStyle.danger,
custom_id="ticket_close",
)
async def callback(self, interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
ticket = storage.get_ticket(interaction.channel.id)
if not ticket:
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
return
member = interaction.guild.get_member(interaction.user.id)
if not member or not _is_staff(member, config):
await interaction.response.send_message("Seul le staff peut fermer un ticket.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
ticket.status = TicketStatus.CLOSED
ticket.closed_at = _now_iso()
ticket.closed_by = interaction.user.id
if config.transcript_enabled:
html = generate_transcript(ticket, interaction.guild.name)
transcript_file = discord.File(
fp=__import__("io").BytesIO(html.encode()),
filename=f"ticket_{ticket.channel_id}.html",
)
if config.log_channel_id:
ch = interaction.guild.get_channel(config.log_channel_id)
if ch:
with contextlib.suppress(discord.HTTPException):
await ch.send(
f"Ticket ferme par {interaction.user.mention}",
file=transcript_file,
)
for msg in ticket.messages:
if not msg.is_staff:
user = interaction.guild.get_member(msg.author_id)
if user:
with contextlib.suppress(discord.HTTPException):
await user.send(
"Ton ticket a ete ferme.",
file=discord.File(
fp=__import__("io").BytesIO(html.encode()),
filename=f"ticket_{ticket.channel_id}.html",
),
)
break
storage.save_ticket(ticket)
embed = discord.Embed(
title="Ticket ferme",
description=f"**Ferme par :** {interaction.user.mention}\n**Categorie :** {ticket.category_id}",
color=discord.Color.red(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, config, embed)
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay("## Ticket ferme\nCe ticket sera supprime dans 5 secondes."))
view = discord.ui.LayoutView()
view.add_item(container)
await interaction.followup.send(view=view)
await asyncio.sleep(5)
with contextlib.suppress(discord.HTTPException):
await interaction.channel.delete(reason=f"Ticket ferme par {interaction.user}")

View file

@ -1,115 +0,0 @@
import json
from config import DATA_DIR
CONFIG_PATH = DATA_DIR / "modmail.json"
DEFAULT_CONFIG = {
"enabled": False,
"category_id": None,
"staff_role_id": None,
"log_channel_id": None,
"categories": {},
"open_tickets": {},
}
def _load() -> dict:
if CONFIG_PATH.exists():
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
return DEFAULT_CONFIG.copy()
def _save(data: dict):
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_config() -> dict:
return _load()
def set_guild_id(guild_id: int):
data = _load()
data["guild_id"] = guild_id
_save(data)
def set_enabled(guild_id: int, enabled: bool):
data = _load()
data["enabled"] = enabled
_save(data)
def set_category(guild_id: int, category_id: int):
data = _load()
data["category_id"] = category_id
_save(data)
def set_staff_role(guild_id: int, role_id: int):
data = _load()
data["staff_role_id"] = role_id
_save(data)
def set_log_channel(guild_id: int, channel_id: int):
data = _load()
data["log_channel_id"] = channel_id
_save(data)
def add_category(guild_id: int, name: str, emoji: str, description: str = ""):
data = _load()
data["categories"][name.lower()] = {
"name": name,
"emoji": emoji,
"description": description,
}
_save(data)
def remove_category(guild_id: int, name: str):
data = _load()
data["categories"].pop(name.lower(), None)
_save(data)
def get_categories() -> dict:
data = _load()
return data.get("categories", {})
def register_ticket(guild_id: int, user_id: int, channel_id: int, category: str):
data = _load()
data["open_tickets"][str(user_id)] = {
"channel_id": channel_id,
"category": category,
"guild_id": guild_id,
}
_save(data)
def unregister_ticket(user_id: int):
data = _load()
data["open_tickets"].pop(str(user_id), None)
_save(data)
def get_ticket_by_user(user_id: int) -> dict | None:
data = _load()
return data["open_tickets"].get(str(user_id))
def get_ticket_by_channel(channel_id: int) -> dict | None:
data = _load()
for uid, ticket in data.get("open_tickets", {}).items():
if ticket["channel_id"] == channel_id:
return {"user_id": int(uid), **ticket}
return None
def is_enabled() -> bool:
return _load().get("enabled", False)

View file

@ -1,215 +0,0 @@
import html
from datetime import datetime, timezone
def _format_timestamp(dt: datetime) -> str:
return dt.strftime("%d/%m/%Y %H:%M:%S")
def _avatar_url(user) -> str:
if user.avatar:
return user.avatar.url
return f"https://cdn.discordapp.com/embed/avatars/{user.discriminator % 5}.png"
def generate_transcript(messages: list[dict], ticket_info: dict) -> str:
"""Génère un transcript HTML stylé.
messages: [{"author": discord.User, "content": str, "timestamp": datetime, "attachments": list}]
ticket_info: {"category": str, "user_id": int, "channel_id": int}
"""
now = datetime.now(timezone.utc)
date_str = _format_timestamp(now)
messages_html = ""
for msg in messages:
author = msg["author"]
content = html.escape(msg["content"]) if msg["content"] else ""
timestamp = _format_timestamp(msg["timestamp"])
avatar = _avatar_url(author)
is_staff = msg.get("is_staff", False)
staff_badge = '<span class="badge badge-staff">STAFF</span>' if is_staff else ""
attachments_html = ""
for att in msg.get("attachments", []):
if att.url.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
attachments_html += (
f'<div class="attachment"><img src="{html.escape(att.url)}" alt="attachment" /></div>'
)
else:
attachments_html += (
f'<div class="attachment"><a href="{html.escape(att.url)}">{html.escape(att.filename)}</a></div>'
)
messages_html += f"""
<div class="message {"staff" if is_staff else "user"}">
<div class="msg-header">
<img class="avatar" src="{avatar}" alt="{html.escape(str(author))}" />
<span class="author">{html.escape(str(author))}</span>
{staff_badge}
<span class="timestamp">{timestamp}</span>
</div>
<div class="msg-content">{content}</div>
{attachments_html}
</div>
"""
if not messages_html:
messages_html = '<div class="empty">Aucun message dans ce ticket.</div>'
return 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_info.get("channel_id", 0)}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #0b0d11;
color: #dcddde;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, #5865f2, #7289da);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
text-align: center;
}}
.header h1 {{
font-size: 24px;
color: #fff;
margin-bottom: 8px;
}}
.header .meta {{
font-size: 14px;
color: #d4d4d4;
}}
.header .meta span {{
background: rgba(255,255,255,0.15);
padding: 2px 10px;
border-radius: 20px;
margin: 0 4px;
font-size: 13px;
}}
.messages {{
max-width: 800px;
margin: 0 auto;
}}
.message {{
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
transition: background 0.15s;
}}
.message:hover {{
background: rgba(255,255,255,0.03);
}}
.message.staff {{
border-left: 3px solid #57f287;
background: rgba(87, 242, 135, 0.03);
}}
.message.user {{
border-left: 3px solid #5865f2;
}}
.msg-header {{
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}}
.avatar {{
width: 32px;
height: 32px;
border-radius: 50%;
}}
.author {{
font-weight: 600;
font-size: 15px;
color: #fff;
}}
.badge {{
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}}
.badge-staff {{
background: #57f287;
color: #000;
}}
.timestamp {{
font-size: 12px;
color: #72767d;
margin-left: auto;
}}
.msg-content {{
font-size: 14px;
line-height: 1.5;
color: #dcddde;
white-space: pre-wrap;
word-break: break-word;
margin-left: 42px;
}}
.attachment {{
margin-left: 42px;
margin-top: 6px;
}}
.attachment img {{
max-width: 400px;
max-height: 300px;
border-radius: 8px;
border: 1px solid #2f3136;
}}
.attachment a {{
color: #00aff4;
text-decoration: none;
}}
.empty {{
text-align: center;
color: #72767d;
padding: 40px;
font-size: 16px;
}}
.footer {{
text-align: center;
color: #4f545c;
font-size: 12px;
margin-top: 32px;
padding: 16px;
border-top: 1px solid #2f3136;
}}
</style>
</head>
<body>
<div class="header">
<h1>Transcript — Ticket #{ticket_info.get("channel_id", 0)}</h1>
<div class="meta">
<span>Catégorie: {html.escape(ticket_info.get("category", "N/A"))}</span>
<span>Utilisateur: {ticket_info.get("user_id", "N/A")}</span>
<span>Généré le: {date_str}</span>
</div>
</div>
<div class="messages">
{messages_html}
</div>
<div class="footer">
Vision Studio — Transcript généré automatiquement
</div>
</body>
</html>"""
def save_transcript(html_content: str, channel_id: int) -> str:
from config import DATA_DIR
transcripts_dir = DATA_DIR / "transcripts"
transcripts_dir.mkdir(parents=True, exist_ok=True)
path = transcripts_dir / f"ticket_{channel_id}_{int(datetime.now(timezone.utc).timestamp())}.html"
with open(path, "w", encoding="utf-8") as f:
f.write(html_content)
return str(path)