Merge branch 'dev' into main — ticket system v2
All checks were successful
deploy / deploy (push) Successful in 22s

This commit is contained in:
Mathis 2026-10-02 19:36:28 +02:00
commit 33b9303948
15 changed files with 2069 additions and 952 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,620 +0,0 @@
import contextlib
import logging
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):
try:
category_key = self.values[0]
categories = cfg.get_categories()
cat = categories.get(category_key)
if not cat:
await interaction.response.send_message("Cette categorie n'existe plus.", ephemeral=True)
return
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)))
except Exception as e:
logging.getLogger("vision").error(f"CategorySelect callback error: {e}", exc_info=True)
if not interaction.response.is_done():
await interaction.response.send_message(f"Erreur interne: `{e}`", ephemeral=True)
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()
if self.category_key not in cfg.get_categories():
await interaction.followup.send("La categorie n'existe plus. Reessayez.", ephemeral=True)
return
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())
container.add_item(discord.ui.ActionRow(CloseTicketButton()))
view = discord.ui.LayoutView()
view.add_item(container)
await channel.send(view=view)
class CloseTicketButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Fermer le ticket",
style=discord.ButtonStyle.danger,
custom_id="modmail_close_ticket",
)
async def callback(self, interaction: discord.Interaction):
cog = interaction.client.get_cog("ModMail")
if cog:
await cog._close_ticket(interaction.channel, interaction.user)
class OpenTicketButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Ouvrir le ticket",
style=discord.ButtonStyle.primary,
custom_id="modmail_quick_open",
)
async def callback(self, interaction: discord.Interaction):
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]] = {}
# --- 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
categories = cfg.get_categories()
if not categories:
with contextlib.suppress(discord.HTTPException):
await message.author.send(
"Le systeme de tickets n'est pas encore configure. "
"Un administrateur doit d'abord configurer les categories."
)
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']}**.")
)
container.add_item(discord.ui.ActionRow(OpenTicketButton()))
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))

244
tests/test_data.py Normal file
View file

@ -0,0 +1,244 @@
from __future__ import annotations
from tickets.data import (
GuildTicketConfig,
TicketCategory,
TicketData,
TicketMessage,
TicketPriority,
TicketStatus,
)
class TestTicketStatus:
def test_open_value(self):
assert TicketStatus.OPEN.value == "open"
def test_closed_value(self):
assert TicketStatus.CLOSED.value == "closed"
def test_enum_inheritance(self):
assert issubclass(TicketStatus, str)
class TestTicketPriority:
def test_all_values(self):
assert TicketPriority.LOW.value == "low"
assert TicketPriority.NORMAL.value == "normal"
assert TicketPriority.HIGH.value == "high"
assert TicketPriority.URGENT.value == "urgent"
class TestTicketCategory:
def test_create_default(self):
cat = TicketCategory(id="test", name="Test")
assert cat.id == "test"
assert cat.name == "Test"
assert cat.emoji == ""
assert cat.description == ""
assert cat.staff_role_id is None
assert cat.discord_category_id is None
def test_to_dict(self):
cat = TicketCategory(
id="support",
name="Support",
emoji="🎫",
description="Aide",
staff_role_id=123,
discord_category_id=456,
)
d = cat.to_dict()
assert d["id"] == "support"
assert d["name"] == "Support"
assert d["emoji"] == "🎫"
assert d["description"] == "Aide"
assert d["staff_role_id"] == 123
assert d["discord_category_id"] == 456
def test_from_dict(self):
d = {
"id": "support",
"name": "Support",
"emoji": "🎫",
"description": "Aide",
"staff_role_id": 123,
"discord_category_id": 456,
}
cat = TicketCategory.from_dict(d)
assert cat.id == "support"
assert cat.name == "Support"
assert cat.staff_role_id == 123
def test_from_dict_ignores_extra_keys(self):
d = {"id": "x", "name": "X", "unknown_key": "should be ignored"}
cat = TicketCategory.from_dict(d)
assert cat.id == "x"
def test_roundtrip(self):
cat = TicketCategory(id="a", name="A", emoji="🔧", description="desc")
cat2 = TicketCategory.from_dict(cat.to_dict())
assert cat2 == cat
class TestTicketMessage:
def test_create_minimal(self):
msg = TicketMessage(author_id=1, author_name="user", content="hello", timestamp="2026-01-01")
assert msg.attachments == []
assert msg.is_staff is False
def test_create_full(self):
msg = TicketMessage(
author_id=1,
author_name="user",
content="hello",
timestamp="2026-01-01",
attachments=["a.png"],
is_staff=True,
)
assert msg.attachments == ["a.png"]
assert msg.is_staff is True
class TestTicketData:
def test_create_minimal(self):
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="cat")
assert t.status == TicketStatus.OPEN
assert t.priority == TicketPriority.NORMAL
assert t.locked is False
assert t.claimed_by is None
assert t.messages == []
assert t.reason == ""
def test_to_dict(self):
t = TicketData(
channel_id=100,
guild_id=200,
user_id=300,
category_id="support",
status=TicketStatus.CLOSED,
priority=TicketPriority.HIGH,
claimed_by=999,
locked=True,
reason="test",
)
d = t.to_dict()
assert d["channel_id"] == 100
assert d["status"] == "closed"
assert d["priority"] == "high"
assert d["claimed_by"] == 999
assert d["locked"] is True
assert d["reason"] == "test"
def test_to_dict_with_messages(self):
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="c")
t.messages.append(TicketMessage(author_id=1, author_name="a", content="hi", timestamp="t"))
d = t.to_dict()
assert len(d["messages"]) == 1
assert d["messages"][0]["content"] == "hi"
def test_from_dict(self):
d = {
"channel_id": 1,
"guild_id": 2,
"user_id": 3,
"category_id": "c",
"status": "closed",
"priority": "high",
"claimed_by": 99,
"locked": True,
"reason": "r",
"messages": [{"author_id": 1, "author_name": "a", "content": "hi", "timestamp": "t"}],
}
t = TicketData.from_dict(d)
assert t.status == TicketStatus.CLOSED
assert t.priority == TicketPriority.HIGH
assert t.claimed_by == 99
assert t.locked is True
assert len(t.messages) == 1
def test_from_dict_legacy_keys(self):
d = {
"channel_id": 1,
"guild_id": 2,
"user_id": 3,
"category_id": "c",
"transcript_path": "/old/path.html",
"rating": 5,
"feedback": "ok",
}
t = TicketData.from_dict(d)
assert not hasattr(t, "transcript_path")
assert not hasattr(t, "rating")
assert not hasattr(t, "feedback")
def test_roundtrip(self):
t = TicketData(
channel_id=10,
guild_id=20,
user_id=30,
category_id="cat",
status=TicketStatus.CLOSED,
priority=TicketPriority.URGENT,
claimed_by=50,
locked=True,
reason="test reason",
created_at="2026-01-01T00:00:00",
closed_at="2026-01-02T00:00:00",
)
t.messages.append(TicketMessage(author_id=1, author_name="u", content="m", timestamp="t"))
t2 = TicketData.from_dict(t.to_dict())
assert t2.channel_id == t.channel_id
assert t2.status == t.status
assert t2.priority == t.priority
assert t2.claimed_by == t.claimed_by
assert t2.locked == t.locked
assert len(t2.messages) == 1
class TestGuildTicketConfig:
def test_create_default(self):
c = GuildTicketConfig(guild_id=1)
assert c.enabled is False
assert c.categories == []
assert c.staff_role_id is None
assert c.log_channel_id is None
assert c.transcript_enabled is True
def test_to_dict(self):
c = GuildTicketConfig(
guild_id=1,
enabled=True,
staff_role_id=100,
log_channel_id=200,
panel_channel_id=300,
panel_message_id=400,
)
c.categories.append(TicketCategory(id="a", name="A"))
d = c.to_dict()
assert d["guild_id"] == 1
assert d["enabled"] is True
assert d["staff_role_id"] == 100
assert d["log_channel_id"] == 200
assert len(d["categories"]) == 1
def test_from_dict(self):
d = {
"guild_id": 1,
"enabled": True,
"staff_role_id": 100,
"log_channel_id": 200,
"categories": [{"id": "a", "name": "A"}],
}
c = GuildTicketConfig.from_dict(d)
assert c.enabled is True
assert len(c.categories) == 1
assert c.categories[0].id == "a"
def test_roundtrip(self):
c = GuildTicketConfig(guild_id=42, enabled=True)
c.categories.append(TicketCategory(id="x", name="X", emoji="🔧"))
c2 = GuildTicketConfig.from_dict(c.to_dict())
assert c2.guild_id == 42
assert c2.enabled is True
assert c2.categories[0].id == "x"

130
tests/test_storage.py Normal file
View file

@ -0,0 +1,130 @@
from __future__ import annotations
import json
import pytest
from tickets import storage
from tickets.data import GuildTicketConfig, TicketCategory, TicketData
@pytest.fixture(autouse=True)
def clean_storage(tmp_path):
"""Isolate storage in a temp dir and reset caches."""
storage.TICKETS_DIR = tmp_path / "tickets"
storage._CONFIG_FILE = storage.TICKETS_DIR / "config.json"
storage._config_cache.clear()
storage._tickets_cache.clear()
yield
storage._config_cache.clear()
storage._tickets_cache.clear()
class TestGetConfig:
def test_returns_default_when_missing(self):
config = storage.get_config(guild_id=123)
assert config.guild_id == 123
assert config.enabled is False
assert config.categories == []
def test_returns_same_object(self):
c1 = storage.get_config(guild_id=1)
c2 = storage.get_config(guild_id=1)
assert c1 is c2
def test_different_guilds_different_configs(self):
c1 = storage.get_config(guild_id=1)
c2 = storage.get_config(guild_id=2)
assert c1.guild_id != c2.guild_id
class TestSaveConfig:
def test_save_creates_file(self, tmp_path):
config = storage.get_config(guild_id=1)
config.enabled = True
storage.save_config(config)
assert storage._CONFIG_FILE.exists()
def test_save_persists(self, tmp_path):
config = storage.get_config(guild_id=1)
config.enabled = True
config.categories.append(TicketCategory(id="a", name="A"))
storage.save_config(config)
storage._config_cache.clear()
loaded = storage.get_config(guild_id=1)
assert loaded.enabled is True
assert len(loaded.categories) == 1
assert loaded.categories[0].id == "a"
def test_save_multiple_guilds(self):
c1 = storage.get_config(guild_id=1)
c1.enabled = True
storage.save_config(c1)
c2 = storage.get_config(guild_id=2)
c2.enabled = False
storage.save_config(c2)
storage._config_cache.clear()
assert storage.get_config(guild_id=1).enabled is True
assert storage.get_config(guild_id=2).enabled is False
class TestSaveAndGetTicket:
def test_get_missing_returns_none(self):
assert storage.get_ticket(999) is None
def test_save_and_get(self):
ticket = TicketData(channel_id=100, guild_id=1, user_id=2, category_id="cat")
storage.save_ticket(ticket)
loaded = storage.get_ticket(100)
assert loaded is not None
assert loaded.channel_id == 100
assert loaded.category_id == "cat"
def test_save_persists_to_disk(self):
ticket = TicketData(channel_id=200, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
storage._tickets_cache.clear()
loaded = storage.get_ticket(200)
assert loaded is not None
assert loaded.user_id == 2
def test_save_overwrites(self):
t1 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="old")
storage.save_ticket(t1)
t2 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="new")
storage.save_ticket(t2)
loaded = storage.get_ticket(300)
assert loaded.category_id == "new"
def test_multiple_tickets(self):
t1 = TicketData(channel_id=400, guild_id=1, user_id=2, category_id="a")
t2 = TicketData(channel_id=401, guild_id=1, user_id=3, category_id="b")
storage.save_ticket(t1)
storage.save_ticket(t2)
assert storage.get_ticket(400).category_id == "a"
assert storage.get_ticket(401).category_id == "b"
class TestDeleteTicket:
def test_delete_removes_file(self):
ticket = TicketData(channel_id=500, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
assert storage.get_ticket(500) is not None
storage.delete_ticket(500)
assert storage.get_ticket(500) is None
def test_delete_nonexistent(self):
storage.delete_ticket(9999)
def test_delete_clears_cache(self):
ticket = TicketData(channel_id=600, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
storage.delete_ticket(600)
assert 600 not in storage._tickets_cache

133
tests/test_transcript.py Normal file
View file

@ -0,0 +1,133 @@
from __future__ import annotations
from tickets.data import TicketData, TicketMessage
from tickets.transcript import _format_ts, generate_transcript
class TestFormatTs:
def test_valid_iso(self):
result = _format_ts("2026-01-15T10:30:00+00:00")
assert result == "15/01/2026 10:30:00"
def test_valid_naive_iso(self):
result = _format_ts("2026-06-01T12:00:00")
assert result == "01/06/2026 12:00:00"
def test_invalid_string_returns_as_is(self):
assert _format_ts("not-a-date") == "not-a-date"
def test_empty_string(self):
assert _format_ts("") == ""
def test_none_returns_none(self):
assert _format_ts(None) is None
def _make_ticket(**kwargs) -> TicketData:
defaults = dict(channel_id=123, guild_id=456, user_id=789, category_id="support")
defaults.update(kwargs)
return TicketData(**defaults)
class TestGenerateTranscript:
def test_returns_html(self):
result = generate_transcript(_make_ticket())
assert result.startswith("<!DOCTYPE html>")
assert "</html>" in result
def test_contains_channel_id(self):
result = generate_transcript(_make_ticket(channel_id=42))
assert "Ticket #42" in result
def test_empty_messages(self):
ticket = _make_ticket()
result = generate_transcript(ticket)
assert "Aucun message" in result
def test_single_user_message(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(author_id=1, author_name="TestUser", content="Hello!", timestamp="2026-01-01T00:00:00")
)
result = generate_transcript(ticket)
assert "TestUser" in result
assert "Hello!" in result
assert 'class="badge badge-staff"' not in result
def test_staff_message_has_badge(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=2, author_name="StaffUser", content="Reply", timestamp="2026-01-01T00:00:00", is_staff=True
)
)
result = generate_transcript(ticket)
assert "badge-staff" in result
assert "STAFF" in result
def test_message_content_escaped(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(author_id=1, author_name="x", content="<script>alert('xss')</script>", timestamp="t")
)
result = generate_transcript(ticket)
assert "<script>" not in result
assert "&lt;script&gt;" in result
def test_image_attachment(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=1,
author_name="u",
content="pic",
timestamp="t",
attachments=["https://example.com/image.png"],
)
)
result = generate_transcript(ticket)
assert "<img" in result
assert "image.png" in result
def test_file_attachment(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=1,
author_name="u",
content="file",
timestamp="t",
attachments=["https://example.com/doc.pdf"],
)
)
result = generate_transcript(ticket)
assert "<a href=" in result
assert "doc.pdf" in result
def test_closed_ticket_shows_closed_at(self):
ticket = _make_ticket(closed_at="2026-01-02T10:00:00+00:00")
result = generate_transcript(ticket)
assert "Ferme" in result or "Fermé" in result
def test_open_ticket_no_closed_at(self):
ticket = _make_ticket()
result = generate_transcript(ticket)
assert "Ferme" not in result
def test_dark_theme_colors(self):
result = generate_transcript(_make_ticket())
assert "#0b0d11" in result
def test_multiple_messages(self):
ticket = _make_ticket()
for i in range(5):
ticket.messages.append(
TicketMessage(author_id=i, author_name=f"User{i}", content=f"Message {i}", timestamp=f"t{i}")
)
result = generate_transcript(ticket)
for i in range(5):
assert f"Message {i}" in result
def test_footer_text(self):
result = generate_transcript(_make_ticket())
assert "Vision Studio" in result

304
tests/test_ui.py Normal file
View file

@ -0,0 +1,304 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import discord
import pytest
from tickets import storage
from tickets.cog import ConfigToggleButton
from tickets.data import GuildTicketConfig, TicketCategory, TicketData, TicketStatus
from tickets.ui import (
AddMemberButton,
ClaimButton,
CloseButton,
LockButton,
PanelCreateButton,
TransferButton,
_is_staff,
_now_iso,
_send_log,
)
STAFF_ROLE_ID = 999999
@pytest.fixture(autouse=True)
def clean_storage():
storage._config_cache.clear()
storage._tickets_cache.clear()
config = GuildTicketConfig(guild_id=999)
config.staff_role_id = STAFF_ROLE_ID
storage._config_cache[999] = config
yield
storage._config_cache.clear()
storage._tickets_cache.clear()
def _make_member(is_admin=False, has_staff_role=True):
member = MagicMock(spec=discord.Member)
member.guild_permissions.administrator = is_admin
if has_staff_role:
member.roles = [MagicMock(id=STAFF_ROLE_ID)]
else:
member.roles = []
return member
def _mock_interaction(guild_id=999, user_id=1, channel_id=123, member=None):
if member is None:
member = _make_member()
interaction = MagicMock(spec=discord.Interaction)
interaction.guild_id = guild_id
interaction.channel_id = channel_id
interaction.channel = MagicMock()
interaction.channel.id = channel_id
interaction.user = MagicMock()
interaction.user.id = user_id
interaction.user.mention = f"<@{user_id}>"
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.guild = MagicMock(spec=discord.Guild)
interaction.guild.id = guild_id
interaction.guild.get_member.return_value = member
interaction.channel.overwrites_for = MagicMock()
interaction.channel.set_permissions = AsyncMock()
interaction.channel.delete = AsyncMock()
interaction.channel.send = AsyncMock()
return interaction
def _save_ticket(channel_id=123, **kwargs):
defaults = dict(channel_id=channel_id, guild_id=999, user_id=1, category_id="cat")
defaults.update(kwargs)
ticket = TicketData(**defaults)
storage.save_ticket(ticket)
return ticket
class TestIsStaff:
def test_admin(self):
member = _make_member(is_admin=True)
config = storage.get_config(999)
assert _is_staff(member, config) is True
def test_staff_role_match(self):
member = _make_member(has_staff_role=True)
config = storage.get_config(999)
assert _is_staff(member, config) is True
def test_not_staff(self):
member = _make_member(has_staff_role=False)
config = storage.get_config(999)
assert _is_staff(member, config) is False
def test_category_staff_role(self):
member = _make_member(has_staff_role=False)
member.roles = [MagicMock(id=77777)]
config = storage.get_config(999)
config.categories = [TicketCategory(id="c", name="C", staff_role_id=77777)]
assert _is_staff(member, config) is True
class TestNowIso:
def test_returns_iso_string(self):
result = _now_iso()
assert "T" in result
class TestSendLog:
@pytest.mark.asyncio
async def test_no_log_channel(self):
config = storage.get_config(999)
guild = MagicMock()
guild.get_channel.return_value = None
await _send_log(guild, config, MagicMock())
guild.get_channel.assert_not_called()
@pytest.mark.asyncio
async def test_sends_to_log_channel(self):
config = storage.get_config(999)
config.log_channel_id = 555
channel = AsyncMock()
guild = MagicMock()
guild.get_channel.return_value = channel
embed = MagicMock()
await _send_log(guild, config, embed)
channel.send.assert_called_once_with(embed=embed)
class TestPanelCreateButton:
@pytest.mark.asyncio
async def test_disabled_system(self):
config = storage.get_config(999)
config.enabled = False
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_no_categories(self):
config = storage.get_config(999)
config.enabled = True
config.categories = []
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_single_category_opens_modal(self):
config = storage.get_config(999)
config.enabled = True
config.categories = [TicketCategory(id="c1", name="Cat1")]
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_modal.assert_called_once()
@pytest.mark.asyncio
async def test_multiple_categories_sends_select(self):
config = storage.get_config(999)
config.enabled = True
config.categories = [
TicketCategory(id="c1", name="Cat1"),
TicketCategory(id="c2", name="Cat2"),
]
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
call_kwargs = interaction.response.send_message.call_args.kwargs
assert "view" in call_kwargs
class TestClaimButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_not_staff(self):
_save_ticket()
member = _make_member(is_admin=False, has_staff_role=False)
interaction = _mock_interaction(member=member)
await ClaimButton().callback(interaction)
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
@pytest.mark.asyncio
async def test_claim_success(self):
_save_ticket()
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by == 1
@pytest.mark.asyncio
async def test_unclaim_own(self):
_save_ticket(claimed_by=1)
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by is None
@pytest.mark.asyncio
async def test_already_claimed_by_other(self):
_save_ticket(claimed_by=42)
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by == 42
class TestLockButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await LockButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_lock_toggle(self):
_save_ticket(locked=False)
interaction = _mock_interaction()
interaction.channel.overwrites_for.return_value = MagicMock()
await LockButton().callback(interaction)
assert storage.get_ticket(123).locked is True
interaction.channel.set_permissions.assert_called_once()
@pytest.mark.asyncio
async def test_unlock_toggle(self):
_save_ticket(locked=True)
interaction = _mock_interaction()
interaction.channel.overwrites_for.return_value = MagicMock()
await LockButton().callback(interaction)
assert storage.get_ticket(123).locked is False
class TestCloseButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await CloseButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_not_staff(self):
_save_ticket()
member = _make_member(is_admin=False, has_staff_role=False)
interaction = _mock_interaction(member=member)
await CloseButton().callback(interaction)
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
@pytest.mark.asyncio
async def test_close_success(self):
_save_ticket()
interaction = _mock_interaction()
await CloseButton().callback(interaction)
loaded = storage.get_ticket(123)
assert loaded.status == TicketStatus.CLOSED
assert loaded.closed_by == 1
@pytest.mark.asyncio
async def test_close_without_transcript(self):
config = storage.get_config(999)
config.transcript_enabled = False
_save_ticket()
interaction = _mock_interaction()
await CloseButton().callback(interaction)
assert storage.get_ticket(123).status == TicketStatus.CLOSED
class TestTransferButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await TransferButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_sends_user_select(self):
_save_ticket()
interaction = _mock_interaction()
await TransferButton().callback(interaction)
assert "view" in interaction.response.send_message.call_args.kwargs
class TestAddMemberButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await AddMemberButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_sends_user_select(self):
_save_ticket()
interaction = _mock_interaction()
await AddMemberButton().callback(interaction)
assert "view" in interaction.response.send_message.call_args.kwargs
class TestConfigToggleButton:
@pytest.mark.asyncio
async def test_toggle(self):
config = storage.get_config(999)
initial = config.enabled
interaction = _mock_interaction()
await ConfigToggleButton(enabled=initial).callback(interaction)
assert config.enabled != initial

0
tickets/__init__.py Normal file
View file

243
tickets/cog.py Normal file
View file

@ -0,0 +1,243 @@
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 (
AddMemberButton,
ClaimButton,
CloseButton,
LockButton,
PanelCreateButton,
TransferButton,
_is_staff,
)
logger = logging.getLogger("vision")
class PanelView(discord.ui.LayoutView):
def __init__(self):
super().__init__(timeout=None)
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
"## Système de tickets\n"
"Besoin d'aide ? Créez un ticket et notre équipe vous répondra.\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)
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 enregistrées."))
self.add_item(container)
class AddCategoryModal(discord.ui.Modal, title="Ajouter une catégorie"):
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"Catégorie **{cat.name}** ajoutée.", ephemeral=True)
class ConfigToggleButton(discord.ui.Button):
def __init__(self, enabled: bool):
super().__init__(
label="Désactiver" 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 catégorie", 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):
view = discord.ui.View(timeout=300)
sel = discord.ui.RoleSelect(
placeholder="Role staff...", custom_id="ticket_staff_role_select", min_values=1, max_values=1
)
async def _cb(interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
config.staff_role_id = sel.values[0].id
storage.save_config(config)
await interaction.response.edit_message(content=f"Role staff : {sel.values[0].mention}", view=None)
sel.callback = _cb
view.add_item(sel)
await interaction.response.send_message("Sélectionnez le rôle staff :", view=view, 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):
view = discord.ui.View(timeout=300)
sel = discord.ui.ChannelSelect(
placeholder="Canal de logs...",
custom_id="ticket_log_channel_select",
channel_types=[discord.ChannelType.text],
min_values=1,
max_values=1,
)
async def _cb(interaction: discord.Interaction):
config = storage.get_config(interaction.guild_id)
config.log_channel_id = sel.values[0].id
storage.save_config(config)
await interaction.response.edit_message(content=f"Canal de logs : {sel.values[0].mention}", view=None)
sel.callback = _cb
view.add_item(sel)
await interaction.response.send_message("Sélectionnez le canal de logs :", view=view, ephemeral=True)
class TicketManagementView(discord.ui.LayoutView):
def __init__(self):
super().__init__(timeout=None)
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay("### Gestion"))
container.add_item(discord.ui.ActionRow(ClaimButton(), TransferButton(), LockButton()))
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.ActionRow(AddMemberButton(), CloseButton()))
self.add_item(container)
class TicketSystem(commands.Cog):
ticket = app_commands.Group(name="ticket", description="Systeme de tickets")
def __init__(self, bot: commands.Bot):
self.bot = bot
self.bot.add_view(PanelView())
self.bot.add_view(TicketManagementView())
@commands.Cog.listener()
async def on_ready(self):
logger.info("TicketSystem cog charge")
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot:
return
if not message.guild:
return
ticket = storage.get_ticket(message.channel.id)
if not ticket:
return
config = storage.get_config(message.guild.id)
member = message.guild.get_member(message.author.id)
if not member:
return
is_staff = _is_staff(member, config)
from tickets.data import TicketMessage
msg = TicketMessage(
author_id=message.author.id,
author_name=str(message.author),
content=message.content,
timestamp=message.created_at.isoformat(),
attachments=[a.url for a in message.attachments],
is_staff=is_staff,
)
ticket.messages.append(msg)
storage.save_ticket(ticket)
@ticket.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(self, 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 envoyé\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.command(name="config", description="Configuration du système de tickets")
@app_commands.checks.has_permissions(manage_guild=True)
async def ticket_config(self, 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*"
staff_line = f"**Rôle staff :** <@&{config.staff_role_id}>\n" if config.staff_role_id else ""
log_line = f"**Canal de logs :** <#{config.log_channel_id}>\n" if config.log_channel_id else ""
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
f"## Configuration Tickets\n"
f"**Statut :** {'Actif' if config.enabled else 'Inactif'}\n"
f"{staff_line}{log_line}### 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)
async def setup(bot: commands.Bot):
await bot.add_cog(TicketSystem(bot))

133
tickets/data.py Normal file
View file

@ -0,0 +1,133 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class TicketStatus(str, Enum):
OPEN = "open"
CLOSED = "closed"
class TicketPriority(str, Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
@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
claimed_by: int | None = None
locked: bool = False
priority: TicketPriority = TicketPriority.NORMAL
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,
"claimed_by": self.claimed_by,
"locked": self.locked,
"priority": self.priority.value,
"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["priority"] = TicketPriority(data.get("priority", "normal"))
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.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>"""

619
tickets/ui.py Normal file
View file

@ -0,0 +1,619 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
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="Créer 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 système de tickets est désactivé.", ephemeral=True)
return
categories = config.categories
if not categories:
await interaction.response.send_message("Aucune catégorie configurée.", 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 catégorie :",
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="Sélectionnez une catégorie...",
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("Catégorie 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="Décris ton problème",
style=discord.TextStyle.paragraph,
placeholder="Explique brièvement 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("Catégorie 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)
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}>"
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay(mention))
container.add_item(discord.ui.Separator())
container.add_item(
discord.ui.TextDisplay(
f"## Ticket — {cat.name}\n"
f"**Utilisateur :** {interaction.user.mention}\n"
f"**Catégorie :** {cat.emoji} {cat.name}\n"
f"**Raison :**\n{self.reason.value}"
)
)
container.add_item(discord.ui.Separator())
container.add_item(discord.ui.TextDisplay("### Gestion"))
container.add_item(
discord.ui.ActionRow(
ClaimButton(),
TransferButton(),
LockButton(),
)
)
container.add_item(discord.ui.Separator())
container.add_item(
discord.ui.ActionRow(
AddMemberButton(),
CloseButton(),
)
)
view = discord.ui.LayoutView()
view.add_item(container)
await channel.send(view=view)
await interaction.followup.send(f"Ticket créé : {channel.mention}", ephemeral=True)
embed = discord.Embed(
title="Ticket créé",
description=f"**Catégorie :** {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 ClaimButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Réclamer",
style=discord.ButtonStyle.success,
custom_id="ticket_claim",
)
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 réclamer un ticket.", ephemeral=True)
return
if ticket.claimed_by:
if ticket.claimed_by == interaction.user.id:
ticket.claimed_by = None
storage.save_ticket(ticket)
await interaction.response.send_message(
f"{interaction.user.mention} a retiré son réclamation.", ephemeral=True
)
else:
await interaction.response.send_message(
f"Ce ticket est déjà réclamé par <@{ticket.claimed_by}>.", ephemeral=True
)
return
ticket.claimed_by = interaction.user.id
storage.save_ticket(ticket)
embed = discord.Embed(
title="Ticket claim",
description=f"Réclamé par {interaction.user.mention}",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, config, embed)
container = discord.ui.Container()
container.add_item(discord.ui.TextDisplay(f"## Ticket réclamé\nRéclamé par {interaction.user.mention}"))
view = discord.ui.LayoutView()
view.add_item(container)
await interaction.response.send_message(view=view)
class AddMemberButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Ajouter un membre",
style=discord.ButtonStyle.secondary,
custom_id="ticket_add_member",
)
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 ajouter des membres.", ephemeral=True)
return
await interaction.response.send_message(
"Sélectionnez le membre à ajouter :",
view=AddMemberSelectView(),
ephemeral=True,
)
class AddMemberSelect(discord.ui.UserSelect):
def __init__(self):
super().__init__(
placeholder="Sélectionnez un membre...",
custom_id="ticket_add_member_select",
min_values=1,
max_values=1,
)
async def callback(self, interaction: discord.Interaction):
user = self.values[0]
await interaction.channel.set_permissions(
user,
view_channel=True,
send_messages=True,
read_message_history=True,
)
await interaction.response.edit_message(
content=f"{user.mention} a été ajouté au ticket.",
view=None,
)
embed = discord.Embed(
title="Membre ajouté",
description=f"{user.mention} a été ajouté par {interaction.user.mention}",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
class AddMemberSelectView(discord.ui.View):
def __init__(self):
super().__init__(timeout=300)
self.add_item(AddMemberSelect())
class TransferButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Transférer",
style=discord.ButtonStyle.secondary,
custom_id="ticket_transfer",
)
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 transférer.", ephemeral=True)
return
await interaction.response.send_message(
"Sélectionnez le staff :",
view=TransferSelectView(),
ephemeral=True,
)
class TransferSelect(discord.ui.UserSelect):
def __init__(self):
super().__init__(
placeholder="Sélectionnez un staff...",
custom_id="ticket_transfer_select",
min_values=1,
max_values=1,
)
async def callback(self, interaction: discord.Interaction):
user = self.values[0]
ticket = storage.get_ticket(interaction.channel.id)
if ticket:
ticket.claimed_by = user.id
storage.save_ticket(ticket)
await interaction.response.edit_message(
content=f"Ticket transféré à {user.mention}.",
view=None,
)
embed = discord.Embed(
title="Ticket transféré",
description=f"Transféré à {user.mention} par {interaction.user.mention}",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
class TransferSelectView(discord.ui.View):
def __init__(self):
super().__init__(timeout=300)
self.add_item(TransferSelect())
class PriorityButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Priorite",
style=discord.ButtonStyle.secondary,
custom_id="ticket_priority",
)
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 changer la priorité.", ephemeral=True)
return
await interaction.response.send_message(
"Sélectionnez la priorité :",
view=PrioritySelectView(),
ephemeral=True,
)
class PrioritySelect(discord.ui.Select):
def __init__(self):
super().__init__(
placeholder="Sélectionnez la priorité...",
custom_id="ticket_priority_select",
options=[
discord.SelectOption(label="Basse", value="low", emoji="🟢"),
discord.SelectOption(label="Normale", value="normal", emoji="🔵"),
discord.SelectOption(label="Haute", value="high", emoji="🟠"),
discord.SelectOption(label="Urgente", value="urgent", emoji="🔴"),
],
)
async def callback(self, interaction: discord.Interaction):
from tickets.data import TicketPriority
ticket = storage.get_ticket(interaction.channel.id)
if ticket:
ticket.priority = TicketPriority(self.values[0])
storage.save_ticket(ticket)
await interaction.response.edit_message(
content=f"Priorite definie sur **{self.values[0]}**.",
view=None,
)
embed = discord.Embed(
title="Priorite changee",
description=f"Priorite : **{self.values[0]}** par {interaction.user.mention}",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
class PrioritySelectView(discord.ui.View):
def __init__(self):
super().__init__(timeout=300)
self.add_item(PrioritySelect())
class LockButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Verrouiller",
style=discord.ButtonStyle.secondary,
custom_id="ticket_lock",
)
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 verrouiller/déverrouiller.", ephemeral=True)
return
ticket.locked = not ticket.locked
storage.save_ticket(ticket)
overwrites = interaction.channel.overwrites_for(interaction.guild.default_role)
overwrites.send_messages = not ticket.locked
await interaction.channel.set_permissions(interaction.guild.default_role, overwrite=overwrites)
self.label = "Déverrouiller" if ticket.locked else "Verrouiller"
await interaction.response.send_message(
f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'} par {interaction.user.mention}."
)
embed = discord.Embed(
title=f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'}",
description=f"Par {interaction.user.mention}",
color=discord.Color.orange() if ticket.locked else discord.Color.green(),
timestamp=datetime.now(timezone.utc),
)
await _send_log(interaction.guild, config, embed)
class StatsButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Infos",
style=discord.ButtonStyle.secondary,
custom_id="ticket_stats",
)
async def callback(self, interaction: discord.Interaction):
ticket = storage.get_ticket(interaction.channel.id)
if not ticket:
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
return
config = storage.get_config(interaction.guild_id)
cat = next((c for c in config.categories if c.id == ticket.category_id), None)
user = interaction.guild.get_member(ticket.user_id)
claimed = interaction.guild.get_member(ticket.claimed_by) if ticket.claimed_by else None
msg_count = len(ticket.messages)
container = discord.ui.Container()
container.add_item(
discord.ui.TextDisplay(
f"## Infos Ticket\n"
f"**Categorie :** {cat.emoji} {cat.name if cat else ticket.category_id}\n"
f"**Utilisateur :** {user.mention if user else ticket.user_id}\n"
f"**Claim par :** {claimed.mention if claimed else 'Non claim'}\n"
f"**Priorite :** {ticket.priority.value}\n"
f"**Statut :** {'Lock' if ticket.locked else 'Ouvert'}\n"
f"**Messages :** {msg_count}\n"
f"**Cree :** <t:{int(os.path.fromisoformat(ticket.created_at).timestamp())}:R>"
if ticket.created_at
else ""
)
)
view = discord.ui.LayoutView()
view.add_item(container)
await interaction.response.send_message(view=view, ephemeral=True)
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_content = generate_transcript(ticket, interaction.guild.name)
transcript_file = discord.File(
fp=__import__("io").BytesIO(html_content.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 fermé 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 été fermé.",
file=discord.File(
fp=__import__("io").BytesIO(html_content.encode()),
filename=f"ticket_{ticket.channel_id}.html",
),
)
break
storage.save_ticket(ticket)
embed = discord.Embed(
title="Ticket fermé",
description=f"**Fermé par :** {interaction.user.mention}\n**Catégorie :** {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 fermé\nCe ticket sera supprimé 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 fermé 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)

View file

@ -26,7 +26,8 @@ async def main():
instance = "DEV" if config.DEV else "PROD"
logger.info(f"Lancement de Vision Studio [{instance}]...")
await start_health_server(config.HEALTH_PORT)
if not config.DEV:
await start_health_server(config.HEALTH_PORT)
bot = VisionBot()
try: