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'' + + messages_html += f""" + + """ + + if not messages_html: + messages_html = '