From 57b6f09dd7ffbf253565badb8d7bb8c1edc8aa36 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 20 Sep 2026 20:04:10 +0200 Subject: [PATCH 01/11] feat(ticket): rewrite ticket system from scratch inspired by Kuby 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 --- bot.py | 2 +- commandes/modmail.py | 612 ------------------------------------ tickets/__init__.py | 0 tickets/cog.py | 241 ++++++++++++++ tickets/data.py | 119 +++++++ tickets/storage.py | 86 +++++ tickets/transcript.py | 174 ++++++++++ tickets/ui.py | 284 +++++++++++++++++ utils/modmail_config.py | 115 ------- utils/modmail_transcript.py | 215 ------------- 10 files changed, 905 insertions(+), 943 deletions(-) delete mode 100644 commandes/modmail.py create mode 100644 tickets/__init__.py create mode 100644 tickets/cog.py create mode 100644 tickets/data.py create mode 100644 tickets/storage.py create mode 100644 tickets/transcript.py create mode 100644 tickets/ui.py delete mode 100644 utils/modmail_config.py delete mode 100644 utils/modmail_transcript.py diff --git a/bot.py b/bot.py index 5539e65..a7e7b58 100644 --- a/bot.py +++ b/bot.py @@ -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 diff --git a/commandes/modmail.py b/commandes/modmail.py deleted file mode 100644 index c5516b4..0000000 --- a/commandes/modmail.py +++ /dev/null @@ -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)) diff --git a/tickets/__init__.py b/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tickets/cog.py b/tickets/cog.py new file mode 100644 index 0000000..7c62960 --- /dev/null +++ b/tickets/cog.py @@ -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)) diff --git a/tickets/data.py b/tickets/data.py new file mode 100644 index 0000000..6dee8c4 --- /dev/null +++ b/tickets/data.py @@ -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__}) diff --git a/tickets/storage.py b/tickets/storage.py new file mode 100644 index 0000000..3cf4f70 --- /dev/null +++ b/tickets/storage.py @@ -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() diff --git a/tickets/transcript.py b/tickets/transcript.py new file mode 100644 index 0000000..2e51ba2 --- /dev/null +++ b/tickets/transcript.py @@ -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 = 'STAFF' 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'
' + else: + att_html += f'
{html.escape(att.rsplit("/", 1)[-1])}
' + + messages_html += f""" +
+
+ {html.escape(msg.author_name)} + {badge} + {ts} +
+
{content}
+ {att_html} +
+ """ + + if not messages_html: + messages_html = '
Aucun message.
' + + return f""" + + + + +Transcript — Ticket #{ticket.channel_id} + + + +
+

Ticket #{ticket.channel_id}

+
+ Categorie: {html.escape(ticket.category_id)} + User: {ticket.user_id} + Cree: {_format_ts(ticket.created_at)} + {f"Ferme: {_format_ts(ticket.closed_at)}" if ticket.closed_at else ""} + Genere: {now} +
+
+
{messages_html}
+ + +""" diff --git a/tickets/ui.py b/tickets/ui.py new file mode 100644 index 0000000..81f44ff --- /dev/null +++ b/tickets/ui.py @@ -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}") diff --git a/utils/modmail_config.py b/utils/modmail_config.py deleted file mode 100644 index b840cc4..0000000 --- a/utils/modmail_config.py +++ /dev/null @@ -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) diff --git a/utils/modmail_transcript.py b/utils/modmail_transcript.py deleted file mode 100644 index f5d4a98..0000000 --- a/utils/modmail_transcript.py +++ /dev/null @@ -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 = 'STAFF' 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'
attachment
' - ) - else: - attachments_html += ( - f'
{html.escape(att.filename)}
' - ) - - messages_html += f""" -
-
- {html.escape(str(author))} - {html.escape(str(author))} - {staff_badge} - {timestamp} -
-
{content}
- {attachments_html} -
- """ - - if not messages_html: - messages_html = '
Aucun message dans ce ticket.
' - - return f""" - - - - -Transcript — Ticket #{ticket_info.get("channel_id", 0)} - - - -
-

Transcript — Ticket #{ticket_info.get("channel_id", 0)}

-
- Catégorie: {html.escape(ticket_info.get("category", "N/A"))} - Utilisateur: {ticket_info.get("user_id", "N/A")} - Généré le: {date_str} -
-
-
- {messages_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) From 80daeae55cb11cf56bfea8810d8f5c9c42ea4e4d Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 20 Sep 2026 20:13:07 +0200 Subject: [PATCH 02/11] fix(ticket): move command group inside Cog class for proper sync --- tickets/cog.py | 259 ++++++++++++++++++++----------------------------- 1 file changed, 105 insertions(+), 154 deletions(-) diff --git a/tickets/cog.py b/tickets/cog.py index 7c62960..2884f2a 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -13,16 +13,6 @@ 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) @@ -39,121 +29,6 @@ class PanelView(discord.ui.LayoutView): 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) @@ -186,55 +61,131 @@ class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"): await interaction.response.send_message(f"Categorie **{cat.name}** ajoutee.", ephemeral=True) -class StaffRoleSelect(discord.ui.RoleSelect): - def __init__(self): +class ConfigToggleButton(discord.ui.Button): + def __init__(self, enabled: bool): super().__init__( - placeholder="Role staff...", - custom_id="ticket_staff_role_select", - min_values=1, - max_values=1, + 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.staff_role_id = self.values[0].id + config.enabled = not config.enabled storage.save_config(config) - await interaction.response.edit_message( - content=f"Role staff defini : {self.values[0].mention}", - view=None, + 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): + 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) -class StaffRoleSelectView(discord.ui.View): + sel.callback = _cb + view.add_item(sel) + await interaction.response.send_message("Selectionnez le role staff :", view=view, ephemeral=True) + + +class ConfigSetLogButton(discord.ui.Button): def __init__(self): - super().__init__(timeout=300) - self.add_item(StaffRoleSelect()) + super().__init__(label="Canal logs", style=discord.ButtonStyle.secondary, custom_id="ticket_cfg_log") - -class LogChannelSelect(discord.ui.ChannelSelect): - def __init__(self): - super().__init__( + 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, discord.ChannelType.public_thread], + channel_types=[discord.ChannelType.text], min_values=1, max_values=1, ) - async def callback(self, interaction: discord.Interaction): + 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("Selectionnez le canal de logs :", view=view, ephemeral=True) + + +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()) + + @commands.Cog.listener() + async def on_ready(self): + logger.info("TicketSystem cog charge") + + @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.log_channel_id = self.values[0].id + config.enabled = True + config.panel_channel_id = channel.id storage.save_config(config) - await interaction.response.edit_message( - content=f"Canal de logs defini : {self.values[0].mention}", - view=None, + 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.command(name="config", description="Configuration du systeme 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"**Role 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}" + ) ) - - -class LogChannelSelectView(discord.ui.View): - def __init__(self): - super().__init__(timeout=300) - self.add_item(LogChannelSelect()) + 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): From 4d1e265848247cdb5f62c9c2213bfdc34141e3ec Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 25 Sep 2026 19:28:15 +0200 Subject: [PATCH 03/11] feat(ticket): add claim, add member, persistent management view - ClaimButton: staff claim/unclaim with log - AddMemberButton: UserSelect to add member to ticket channel - TicketManagementView: persistent LayoutView with all 3 buttons - claimed_by field in TicketData - Buttons registered as persistent views for bot restart survival --- tickets/cog.py | 11 ++++- tickets/data.py | 2 + tickets/ui.py | 124 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/tickets/cog.py b/tickets/cog.py index 2884f2a..9359b87 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -8,7 +8,7 @@ from discord.ext import commands from tickets import storage from tickets.data import TicketCategory -from tickets.ui import PanelCreateButton +from tickets.ui import AddMemberButton, ClaimButton, CloseButton, PanelCreateButton logger = logging.getLogger("vision") @@ -130,12 +130,21 @@ class ConfigSetLogButton(discord.ui.Button): await interaction.response.send_message("Selectionnez 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.ActionRow(ClaimButton(), 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): diff --git a/tickets/data.py b/tickets/data.py index 6dee8c4..cfad81c 100644 --- a/tickets/data.py +++ b/tickets/data.py @@ -53,6 +53,7 @@ class TicketData: created_at: str = "" closed_at: str | None = None closed_by: int | None = None + claimed_by: int | None = None messages: list[TicketMessage] = field(default_factory=list) reason: str = "" @@ -66,6 +67,7 @@ class TicketData: "created_at": self.created_at, "closed_at": self.closed_at, "closed_by": self.closed_by, + "claimed_by": self.claimed_by, "messages": [ { "author_id": m.author_id, diff --git a/tickets/ui.py b/tickets/ui.py index 81f44ff..cde08b8 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -185,7 +185,13 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): ) ) container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.ActionRow(CloseButton())) + container.add_item( + discord.ui.ActionRow( + ClaimButton(), + AddMemberButton(), + CloseButton(), + ) + ) view = discord.ui.LayoutView() view.add_item(container) @@ -208,6 +214,122 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): await _send_log(guild, config, embed) +class ClaimButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Claim", + style=discord.ButtonStyle.success, + custom_id="ticket_claim", + emoji="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 claim 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 retire son claim.", ephemeral=True + ) + else: + await interaction.response.send_message( + f"Ce ticket est deja claim 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"Claim 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 claim\nClaim 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", + emoji="Ajouter", + ) + + 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( + "Selectionnez le membre a ajouter :", + view=AddMemberSelectView(), + ephemeral=True, + ) + + +class AddMemberSelect(discord.ui.UserSelect): + def __init__(self): + super().__init__( + placeholder="Selectionnez 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 ete ajoute au ticket.", + view=None, + ) + embed = discord.Embed( + title="Membre ajoute", + description=f"{user.mention} a ete ajoute 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 CloseButton(discord.ui.Button): def __init__(self): super().__init__( From e46c04fe98acefadccdb2a800c09e6c0a3341fa7 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 25 Sep 2026 19:38:24 +0200 Subject: [PATCH 04/11] feat(ticket): add transfer, priority, lock/unlock, stats, message recording - TransferButton: transfer ticket to another staff member - PriorityButton: set ticket priority (low/normal/high/urgent) - LockButton: lock/unlock ticket (toggle channel permissions) - StatsButton: show ticket info (category, user, claimed, priority, messages) - on_message listener: records all messages for transcript - TicketData: added locked, priority fields --- tickets/cog.py | 46 ++++++++++- tickets/data.py | 12 +++ tickets/ui.py | 213 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 2 deletions(-) diff --git a/tickets/cog.py b/tickets/cog.py index 9359b87..823005f 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -8,7 +8,17 @@ from discord.ext import commands from tickets import storage from tickets.data import TicketCategory -from tickets.ui import AddMemberButton, ClaimButton, CloseButton, PanelCreateButton +from tickets.ui import ( + AddMemberButton, + ClaimButton, + CloseButton, + LockButton, + PanelCreateButton, + PriorityButton, + StatsButton, + TransferButton, + _is_staff, +) logger = logging.getLogger("vision") @@ -134,7 +144,8 @@ class TicketManagementView(discord.ui.LayoutView): def __init__(self): super().__init__(timeout=None) container = discord.ui.Container() - container.add_item(discord.ui.ActionRow(ClaimButton(), AddMemberButton(), CloseButton())) + container.add_item(discord.ui.ActionRow(ClaimButton(), AddMemberButton(), TransferButton())) + container.add_item(discord.ui.ActionRow(PriorityButton(), LockButton(), StatsButton(), CloseButton())) self.add_item(container) @@ -150,6 +161,37 @@ class TicketSystem(commands.Cog): 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) diff --git a/tickets/data.py b/tickets/data.py index cfad81c..03b0d4e 100644 --- a/tickets/data.py +++ b/tickets/data.py @@ -9,6 +9,13 @@ class TicketStatus(str, Enum): CLOSED = "closed" +class TicketPriority(str, Enum): + LOW = "low" + NORMAL = "normal" + HIGH = "high" + URGENT = "urgent" + + @dataclass class TicketCategory: id: str @@ -54,6 +61,8 @@ class TicketData: 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 = "" @@ -68,6 +77,8 @@ class TicketData: "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, @@ -86,6 +97,7 @@ class TicketData: 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) diff --git a/tickets/ui.py b/tickets/ui.py index cde08b8..052b2f3 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import contextlib import logging +import os from datetime import datetime, timezone import discord @@ -189,6 +190,14 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): discord.ui.ActionRow( ClaimButton(), AddMemberButton(), + TransferButton(), + ) + ) + container.add_item( + discord.ui.ActionRow( + PriorityButton(), + LockButton(), + StatsButton(), CloseButton(), ) ) @@ -330,6 +339,210 @@ class AddMemberSelectView(discord.ui.View): self.add_item(AddMemberSelect()) +class TransferButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Transfer", + 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 transferer.", ephemeral=True) + return + await interaction.response.send_message( + "Selectionnez le staff :", + view=TransferSelectView(), + ephemeral=True, + ) + + +class TransferSelect(discord.ui.UserSelect): + def __init__(self): + super().__init__( + placeholder="Selectionnez 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 transfere a {user.mention}.", + view=None, + ) + embed = discord.Embed( + title="Ticket transfere", + description=f"Transfere a {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 priorite.", ephemeral=True) + return + await interaction.response.send_message( + "Selectionnez la priorite :", + view=PrioritySelectView(), + ephemeral=True, + ) + + +class PrioritySelect(discord.ui.Select): + def __init__(self): + super().__init__( + placeholder="Selectionnez la priorite...", + 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="Lock", + 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 lock/unlock.", 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 = "Unlock" if ticket.locked else "Lock" + + await interaction.response.send_message( + f"Ticket {'locked' if ticket.locked else 'unlocked'} par {interaction.user.mention}." + ) + + embed = discord.Embed( + title=f"Ticket {'locked' if ticket.locked else 'unlocked'}", + 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 :** " + 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__( From 8f2e121d61ca5db0e5d212fd1a3801f0e2502163 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 25 Sep 2026 19:59:23 +0200 Subject: [PATCH 05/11] fix: skip health server in dev mode (no port needed locally) --- vision.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vision.py b/vision.py index af44c18..42fc021 100644 --- a/vision.py +++ b/vision.py @@ -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: From c959a9753da0ef842f268208d4ebcdb8b4be42d0 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 26 Sep 2026 12:48:39 +0200 Subject: [PATCH 06/11] fix(ticket): remove invalid emoji from buttons causing 400 error --- tickets/ui.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tickets/ui.py b/tickets/ui.py index 052b2f3..d91fecf 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -229,7 +229,6 @@ class ClaimButton(discord.ui.Button): label="Claim", style=discord.ButtonStyle.success, custom_id="ticket_claim", - emoji="Claim", ) async def callback(self, interaction: discord.Interaction): @@ -281,7 +280,6 @@ class AddMemberButton(discord.ui.Button): label="Ajouter un membre", style=discord.ButtonStyle.secondary, custom_id="ticket_add_member", - emoji="Ajouter", ) async def callback(self, interaction: discord.Interaction): From 064a5e65f4ca5976124938fb8bd694f3af6f28bf Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 26 Sep 2026 13:17:11 +0200 Subject: [PATCH 07/11] fix(ticket): move mention into Container TextDisplay (Components V2) --- tickets/ui.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tickets/ui.py b/tickets/ui.py index d91fecf..bb66560 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -176,7 +176,15 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): ) 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" @@ -204,13 +212,7 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): 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 channel.send(view=view) await interaction.followup.send(f"Ticket cree : {channel.mention}", ephemeral=True) From 16fd36ead72f61416d6fc2233400d4a90aefcc19 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 26 Sep 2026 13:19:50 +0200 Subject: [PATCH 08/11] fix(ticket): organize buttons by group (gestion/info/danger) --- tickets/cog.py | 5 +++-- tickets/ui.py | 24 +++++++++--------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tickets/cog.py b/tickets/cog.py index 823005f..4af9f58 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -144,8 +144,9 @@ class TicketManagementView(discord.ui.LayoutView): def __init__(self): super().__init__(timeout=None) container = discord.ui.Container() - container.add_item(discord.ui.ActionRow(ClaimButton(), AddMemberButton(), TransferButton())) - container.add_item(discord.ui.ActionRow(PriorityButton(), LockButton(), StatsButton(), CloseButton())) + container.add_item(discord.ui.ActionRow(ClaimButton(), TransferButton(), LockButton())) + container.add_item(discord.ui.ActionRow(PriorityButton(), AddMemberButton(), StatsButton())) + container.add_item(discord.ui.ActionRow(CloseButton())) self.add_item(container) diff --git a/tickets/ui.py b/tickets/ui.py index bb66560..ff9cf85 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -194,21 +194,15 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): ) ) container.add_item(discord.ui.Separator()) - container.add_item( - discord.ui.ActionRow( - ClaimButton(), - AddMemberButton(), - TransferButton(), - ) - ) - container.add_item( - discord.ui.ActionRow( - PriorityButton(), - LockButton(), - StatsButton(), - CloseButton(), - ) - ) + container.add_item(discord.ui.ActionRow( + ClaimButton(), TransferButton(), LockButton(), + )) + container.add_item(discord.ui.ActionRow( + PriorityButton(), AddMemberButton(), StatsButton(), + )) + container.add_item(discord.ui.ActionRow( + CloseButton(), + )) view = discord.ui.LayoutView() view.add_item(container) From a92dcef08ed375b0c47b3736092baed71ab1035d Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 26 Sep 2026 13:22:14 +0200 Subject: [PATCH 09/11] fix(ticket): add section labels with separators between button groups --- tickets/cog.py | 4 ++++ tickets/ui.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tickets/cog.py b/tickets/cog.py index 4af9f58..bc286d4 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -144,8 +144,12 @@ 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.TextDisplay("### Informations")) container.add_item(discord.ui.ActionRow(PriorityButton(), AddMemberButton(), StatsButton())) + container.add_item(discord.ui.Separator()) container.add_item(discord.ui.ActionRow(CloseButton())) self.add_item(container) diff --git a/tickets/ui.py b/tickets/ui.py index ff9cf85..acec129 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -194,12 +194,16 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): ) ) 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.TextDisplay("### Informations")) container.add_item(discord.ui.ActionRow( PriorityButton(), AddMemberButton(), StatsButton(), )) + container.add_item(discord.ui.Separator()) container.add_item(discord.ui.ActionRow( CloseButton(), )) From 628e02aea94003a73dd991d450d5e2d522a64623 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 26 Sep 2026 13:39:03 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix(ticket):=20boutons=20en=20fran=C3=A7a?= =?UTF-8?q?is,=20accents,=20retrait=20priorit=C3=A9/infos,=E7=BB=84?= =?UTF-8?q?=E7=BB=87=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boutons: Réclamer, Transférer, Verrouiller/Déverrouiller, Ajouter, Fermer - Accents français partout (catégorie, équipe, configuré, etc.) - Retiré PriorityButton et StatsButton (trop chargé) - Layout: Gestion (Réclamer/Transférer/Verrouiller) → Ajouter/Fermer --- tickets/cog.py | 31 ++++++------- tickets/ui.py | 120 +++++++++++++++++++++++++------------------------ 2 files changed, 74 insertions(+), 77 deletions(-) diff --git a/tickets/cog.py b/tickets/cog.py index bc286d4..2c930d9 100644 --- a/tickets/cog.py +++ b/tickets/cog.py @@ -14,8 +14,6 @@ from tickets.ui import ( CloseButton, LockButton, PanelCreateButton, - PriorityButton, - StatsButton, TransferButton, _is_staff, ) @@ -29,8 +27,8 @@ class PanelView(discord.ui.LayoutView): 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" + "## 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." ) ) @@ -43,11 +41,11 @@ 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.")) + container.add_item(discord.ui.TextDisplay("## Configuration Tickets\nModifications enregistrées.")) self.add_item(container) -class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"): +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( @@ -68,13 +66,13 @@ class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"): ) config.categories.append(cat) storage.save_config(config) - await interaction.response.send_message(f"Categorie **{cat.name}** ajoutee.", ephemeral=True) + 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="Desactiver" if enabled else "Activer", + label="Désactiver" if enabled else "Activer", style=discord.ButtonStyle.danger if enabled else discord.ButtonStyle.success, custom_id="ticket_cfg_toggle", ) @@ -88,7 +86,7 @@ class ConfigToggleButton(discord.ui.Button): class ConfigAddCatButton(discord.ui.Button): def __init__(self): - super().__init__(label="Ajouter categorie", style=discord.ButtonStyle.primary, custom_id="ticket_cfg_addcat") + 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()) @@ -112,7 +110,7 @@ class ConfigSetStaffButton(discord.ui.Button): sel.callback = _cb view.add_item(sel) - await interaction.response.send_message("Selectionnez le role staff :", view=view, ephemeral=True) + await interaction.response.send_message("Sélectionnez le rôle staff :", view=view, ephemeral=True) class ConfigSetLogButton(discord.ui.Button): @@ -137,7 +135,7 @@ class ConfigSetLogButton(discord.ui.Button): sel.callback = _cb view.add_item(sel) - await interaction.response.send_message("Selectionnez le canal de logs :", view=view, ephemeral=True) + await interaction.response.send_message("Sélectionnez le canal de logs :", view=view, ephemeral=True) class TicketManagementView(discord.ui.LayoutView): @@ -147,10 +145,7 @@ class TicketManagementView(discord.ui.LayoutView): 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.TextDisplay("### Informations")) - container.add_item(discord.ui.ActionRow(PriorityButton(), AddMemberButton(), StatsButton())) - container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.ActionRow(CloseButton())) + container.add_item(discord.ui.ActionRow(AddMemberButton(), CloseButton())) self.add_item(container) @@ -210,17 +205,17 @@ class TicketSystem(commands.Cog): 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")) + 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 systeme de tickets") + @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"**Role staff :** <@&{config.staff_role_id}>\n" if config.staff_role_id else "" + 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( diff --git a/tickets/ui.py b/tickets/ui.py index acec129..641a926 100644 --- a/tickets/ui.py +++ b/tickets/ui.py @@ -38,7 +38,7 @@ async def _send_log(guild: discord.Guild, config, embed: discord.Embed): class PanelCreateButton(discord.ui.Button): def __init__(self): super().__init__( - label="Creer un ticket", + label="Créer un ticket", style=discord.ButtonStyle.primary, custom_id="ticket_panel_create", ) @@ -46,12 +46,12 @@ class PanelCreateButton(discord.ui.Button): 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) + 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 categorie configuree.", ephemeral=True) + await interaction.response.send_message("Aucune catégorie configurée.", ephemeral=True) return if len(categories) == 1: @@ -60,7 +60,7 @@ class PanelCreateButton(discord.ui.Button): return await interaction.response.send_message( - "Choisissez une categorie :", + "Choisissez une catégorie :", view=CategorySelectView(categories), ephemeral=True, ) @@ -78,7 +78,7 @@ class CategorySelect(discord.ui.Select): for c in categories[:25] ] super().__init__( - placeholder="Selectionnez une categorie...", + placeholder="Sélectionnez une catégorie...", options=options, custom_id="ticket_category_select", ) @@ -88,7 +88,7 @@ class CategorySelect(discord.ui.Select): 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) + await interaction.response.send_message("Catégorie introuvable.", ephemeral=True) return await interaction.response.send_modal(TicketModal(cat.id, cat.name)) @@ -101,9 +101,9 @@ class CategorySelectView(discord.ui.View): class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): reason = discord.ui.TextInput( - label="Decris ton probleme", + label="Décris ton problème", style=discord.TextStyle.paragraph, - placeholder="Explique brievement ta demande...", + placeholder="Explique brièvement ta demande...", required=True, max_length=2000, ) @@ -120,7 +120,7 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): 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) + await interaction.followup.send("Catégorie introuvable.", ephemeral=True) return overwrites = { @@ -189,34 +189,36 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): discord.ui.TextDisplay( f"## Ticket — {cat.name}\n" f"**Utilisateur :** {interaction.user.mention}\n" - f"**Categorie :** {cat.emoji} {cat.name}\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.ActionRow( + ClaimButton(), + TransferButton(), + LockButton(), + ) + ) container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.TextDisplay("### Informations")) - container.add_item(discord.ui.ActionRow( - PriorityButton(), AddMemberButton(), StatsButton(), - )) - container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.ActionRow( - CloseButton(), - )) + 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 cree : {channel.mention}", ephemeral=True) + await interaction.followup.send(f"Ticket créé : {channel.mention}", ephemeral=True) embed = discord.Embed( - title="Ticket cree", - description=f"**Categorie :** {cat.name}\n**Utilisateur :** {interaction.user.mention}\n**Canal :** {channel.mention}", + 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), ) @@ -226,7 +228,7 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): class ClaimButton(discord.ui.Button): def __init__(self): super().__init__( - label="Claim", + label="Réclamer", style=discord.ButtonStyle.success, custom_id="ticket_claim", ) @@ -240,7 +242,7 @@ class ClaimButton(discord.ui.Button): 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 claim un ticket.", ephemeral=True) + await interaction.response.send_message("Seul le staff peut réclamer un ticket.", ephemeral=True) return if ticket.claimed_by: @@ -248,11 +250,11 @@ class ClaimButton(discord.ui.Button): ticket.claimed_by = None storage.save_ticket(ticket) await interaction.response.send_message( - f"{interaction.user.mention} a retire son claim.", ephemeral=True + f"{interaction.user.mention} a retiré son réclamation.", ephemeral=True ) else: await interaction.response.send_message( - f"Ce ticket est deja claim par <@{ticket.claimed_by}>.", ephemeral=True + f"Ce ticket est déjà réclamé par <@{ticket.claimed_by}>.", ephemeral=True ) return @@ -261,14 +263,14 @@ class ClaimButton(discord.ui.Button): embed = discord.Embed( title="Ticket claim", - description=f"Claim par {interaction.user.mention}", + 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 claim\nClaim par {interaction.user.mention}")) + 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) @@ -295,7 +297,7 @@ class AddMemberButton(discord.ui.Button): return await interaction.response.send_message( - "Selectionnez le membre a ajouter :", + "Sélectionnez le membre à ajouter :", view=AddMemberSelectView(), ephemeral=True, ) @@ -304,7 +306,7 @@ class AddMemberButton(discord.ui.Button): class AddMemberSelect(discord.ui.UserSelect): def __init__(self): super().__init__( - placeholder="Selectionnez un membre...", + placeholder="Sélectionnez un membre...", custom_id="ticket_add_member_select", min_values=1, max_values=1, @@ -319,12 +321,12 @@ class AddMemberSelect(discord.ui.UserSelect): read_message_history=True, ) await interaction.response.edit_message( - content=f"{user.mention} a ete ajoute au ticket.", + content=f"{user.mention} a été ajouté au ticket.", view=None, ) embed = discord.Embed( - title="Membre ajoute", - description=f"{user.mention} a ete ajoute par {interaction.user.mention}", + title="Membre ajouté", + description=f"{user.mention} a été ajouté par {interaction.user.mention}", color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) @@ -340,7 +342,7 @@ class AddMemberSelectView(discord.ui.View): class TransferButton(discord.ui.Button): def __init__(self): super().__init__( - label="Transfer", + label="Transférer", style=discord.ButtonStyle.secondary, custom_id="ticket_transfer", ) @@ -353,10 +355,10 @@ class TransferButton(discord.ui.Button): 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 transferer.", ephemeral=True) + await interaction.response.send_message("Seul le staff peut transférer.", ephemeral=True) return await interaction.response.send_message( - "Selectionnez le staff :", + "Sélectionnez le staff :", view=TransferSelectView(), ephemeral=True, ) @@ -365,7 +367,7 @@ class TransferButton(discord.ui.Button): class TransferSelect(discord.ui.UserSelect): def __init__(self): super().__init__( - placeholder="Selectionnez un staff...", + placeholder="Sélectionnez un staff...", custom_id="ticket_transfer_select", min_values=1, max_values=1, @@ -378,12 +380,12 @@ class TransferSelect(discord.ui.UserSelect): ticket.claimed_by = user.id storage.save_ticket(ticket) await interaction.response.edit_message( - content=f"Ticket transfere a {user.mention}.", + content=f"Ticket transféré à {user.mention}.", view=None, ) embed = discord.Embed( - title="Ticket transfere", - description=f"Transfere a {user.mention} par {interaction.user.mention}", + title="Ticket transféré", + description=f"Transféré à {user.mention} par {interaction.user.mention}", color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) @@ -412,10 +414,10 @@ class PriorityButton(discord.ui.Button): 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 priorite.", ephemeral=True) + await interaction.response.send_message("Seul le staff peut changer la priorité.", ephemeral=True) return await interaction.response.send_message( - "Selectionnez la priorite :", + "Sélectionnez la priorité :", view=PrioritySelectView(), ephemeral=True, ) @@ -424,7 +426,7 @@ class PriorityButton(discord.ui.Button): class PrioritySelect(discord.ui.Select): def __init__(self): super().__init__( - placeholder="Selectionnez la priorite...", + placeholder="Sélectionnez la priorité...", custom_id="ticket_priority_select", options=[ discord.SelectOption(label="Basse", value="low", emoji="🟢"), @@ -463,7 +465,7 @@ class PrioritySelectView(discord.ui.View): class LockButton(discord.ui.Button): def __init__(self): super().__init__( - label="Lock", + label="Verrouiller", style=discord.ButtonStyle.secondary, custom_id="ticket_lock", ) @@ -476,7 +478,7 @@ class LockButton(discord.ui.Button): 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 lock/unlock.", ephemeral=True) + await interaction.response.send_message("Seul le staff peut verrouiller/déverrouiller.", ephemeral=True) return ticket.locked = not ticket.locked @@ -486,14 +488,14 @@ class LockButton(discord.ui.Button): overwrites.send_messages = not ticket.locked await interaction.channel.set_permissions(interaction.guild.default_role, overwrite=overwrites) - self.label = "Unlock" if ticket.locked else "Lock" + self.label = "Déverrouiller" if ticket.locked else "Verrouiller" await interaction.response.send_message( - f"Ticket {'locked' if ticket.locked else 'unlocked'} par {interaction.user.mention}." + f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'} par {interaction.user.mention}." ) embed = discord.Embed( - title=f"Ticket {'locked' if ticket.locked else 'unlocked'}", + 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), @@ -568,9 +570,9 @@ class CloseButton(discord.ui.Button): ticket.closed_by = interaction.user.id if config.transcript_enabled: - html = generate_transcript(ticket, interaction.guild.name) + html_content = generate_transcript(ticket, interaction.guild.name) transcript_file = discord.File( - fp=__import__("io").BytesIO(html.encode()), + fp=__import__("io").BytesIO(html_content.encode()), filename=f"ticket_{ticket.channel_id}.html", ) if config.log_channel_id: @@ -578,7 +580,7 @@ class CloseButton(discord.ui.Button): if ch: with contextlib.suppress(discord.HTTPException): await ch.send( - f"Ticket ferme par {interaction.user.mention}", + f"Ticket fermé par {interaction.user.mention}", file=transcript_file, ) @@ -588,9 +590,9 @@ class CloseButton(discord.ui.Button): if user: with contextlib.suppress(discord.HTTPException): await user.send( - "Ton ticket a ete ferme.", + "Ton ticket a été fermé.", file=discord.File( - fp=__import__("io").BytesIO(html.encode()), + fp=__import__("io").BytesIO(html_content.encode()), filename=f"ticket_{ticket.channel_id}.html", ), ) @@ -599,19 +601,19 @@ class CloseButton(discord.ui.Button): storage.save_ticket(ticket) embed = discord.Embed( - title="Ticket ferme", - description=f"**Ferme par :** {interaction.user.mention}\n**Categorie :** {ticket.category_id}", + 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 ferme\nCe ticket sera supprime dans 5 secondes.")) + 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 ferme par {interaction.user}") + await interaction.channel.delete(reason=f"Ticket fermé par {interaction.user}") From e771073909d500cb64cdbf17fb2912c9d77d6282 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 2 Oct 2026 19:34:11 +0200 Subject: [PATCH 11/11] test: 81 unit tests for tickets (data, storage, transcript, ui) - tests/test_data.py: models serialization, enums, roundtrip - tests/test_storage.py: CRUD JSON, cache, persistence - tests/test_transcript.py: HTML generation, escaping, attachments - tests/test_ui.py: button callbacks with mocked interactions - Fixed: transcript.py attachments access (dataclass not dict) --- tests/test_data.py | 244 +++++++++++++++++++++++++++++++ tests/test_storage.py | 130 +++++++++++++++++ tests/test_transcript.py | 133 +++++++++++++++++ tests/test_ui.py | 304 +++++++++++++++++++++++++++++++++++++++ tickets/transcript.py | 2 +- 5 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 tests/test_data.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_transcript.py create mode 100644 tests/test_ui.py diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..382e769 --- /dev/null +++ b/tests/test_data.py @@ -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" diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..acd3fb5 --- /dev/null +++ b/tests/test_storage.py @@ -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 diff --git a/tests/test_transcript.py b/tests/test_transcript.py new file mode 100644 index 0000000..4483d02 --- /dev/null +++ b/tests/test_transcript.py @@ -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("") + assert "" 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="", timestamp="t") + ) + result = generate_transcript(ticket) + assert "