From f505b6c552f47544ee17f3941c16537385296846 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 19 Sep 2026 15:20:08 +0200 Subject: [PATCH] feat: modmail ticket system with Components V2 + HTML transcripts - DM-based modmail: user DMs bot -> ticket channel created - Staff relay: messages in ticket -> DM to user - Configurable categories via /modmail addcategory - Setup via /modmail setup (category, staff role, log channel) - Close command: /close or !close -> HTML transcript generated - Components V2: Container, TextDisplay, Separator, persistent views - Transcript: dark theme HTML with staff badges, avatars, timestamps --- bot.py | 1 + commandes/modmail.py | 514 ++++++++++++++++++++++++++++++++++++ utils/__init__.py | 0 utils/modmail_config.py | 110 ++++++++ utils/modmail_transcript.py | 212 +++++++++++++++ 5 files changed, 837 insertions(+) create mode 100644 commandes/modmail.py create mode 100644 utils/__init__.py create mode 100644 utils/modmail_config.py create mode 100644 utils/modmail_transcript.py diff --git a/bot.py b/bot.py index 09cba96..cb69a35 100644 --- a/bot.py +++ b/bot.py @@ -25,6 +25,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") if config.DEV and DEV_GUILD_IDS: logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}") diff --git a/commandes/modmail.py b/commandes/modmail.py new file mode 100644 index 0000000..bb9191e --- /dev/null +++ b/commandes/modmail.py @@ -0,0 +1,514 @@ +import discord +from discord import app_commands +from discord.ext import commands +from datetime import datetime, timezone + +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) + + try: + await interaction.followup.send( + f"Ticket cree: {ticket_channel.mention}", + ephemeral=True, + ) + except discord.HTTPException: + pass + + 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 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): + data = cfg.get_config() + 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\n" + f"Cliquez 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\n" + "Choisis 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): + data = cfg.get_config() + 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, + }) + + try: + 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) + except discord.HTTPException: + pass + + for att in message.attachments: + try: + await user.send(file=await att.to_file()) + except discord.HTTPException: + pass + + 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 discord.File.from_attachment(att)) + + # --- COMMANDES --- + + modmail_group = app_commands.Group(name="modmail", description="Configuration du modmail") + + @modmail_group.command(name="setup", description="Setup le systeme de modmail") + @app_commands.describe( + category="Category pour les tickets", + staff_role="Role du staff", + log_channel="Canal de logs", + ) + @app_commands.checks.has_permissions(manage_guild=True) + async def setup( + self, + interaction: discord.Interaction, + category: discord.CategoryChannel, + staff_role: discord.Role, + log_channel: discord.TextChannel, + ): + cfg.set_category(interaction.guild_id, category.id) + cfg.set_staff_role(interaction.guild_id, staff_role.id) + cfg.set_log_channel(interaction.guild_id, log_channel.id) + cfg.set_enabled(interaction.guild_id, True) + + data = cfg.get_config() + data["guild_id"] = interaction.guild_id + from utils.modmail_config import _save + _save(data) + + container = discord.ui.Container(accent_colour=discord.Colour.green()) + container.add_item( + discord.ui.TextDisplay( + "## Modmail Configure\n" + f"**Category:** {category.mention}\n" + f"**Staff:** {staff_role.mention}\n" + f"**Logs:** {log_channel.mention}\n" + f"**Status:** Active" + ) + ) + view = discord.ui.LayoutView() + view.add_item(container) + + await interaction.response.send_message(view=view, ephemeral=True) + + @modmail_group.command(name="config", description="Voir la configuration modmail") + @app_commands.checks.has_permissions(manage_guild=True) + async def config_cmd(self, interaction: discord.Interaction): + data = cfg.get_config() + categories = cfg.get_categories() + + cat_list = "\n".join( + f"- {v.get('emoji', '')} **{v['name']}**" for v in categories.values() + ) or "Aucune" + + status = "Active" if data.get("enabled") else "Desactivee" + color = discord.Colour.green() if data.get("enabled") else discord.Colour.red() + + container = discord.ui.Container(accent_colour=color) + container.add_item( + discord.ui.TextDisplay( + f"## Configuration Modmail\n" + f"**Status:** {status}\n" + f"**Category:** <#{data.get('category_id', 0)}>\n" + f"**Staff:** <@&{data.get('staff_role_id', 0)}>\n" + f"**Logs:** <#{data.get('log_channel_id', 0)}>\n\n" + f"### Categories\n{cat_list}" + ) + ) + view = discord.ui.LayoutView() + view.add_item(container) + + await interaction.response.send_message(view=view, ephemeral=True) + + @modmail_group.command(name="addcategory", description="Ajouter une categorie de ticket") + @app_commands.describe(name="Nom", emoji="Emoji", description="Description") + @app_commands.checks.has_permissions(manage_guild=True) + async def addcategory( + self, + interaction: discord.Interaction, + name: str, + emoji: str, + description: str = "", + ): + cfg.add_category(interaction.guild_id, name, emoji, description) + await interaction.response.send_message( + f"Categorie **{name}** ajoutee.", ephemeral=True + ) + + @modmail_group.command(name="removecategory", description="Supprimer une categorie") + @app_commands.describe(name="Nom de la categorie") + @app_commands.checks.has_permissions(manage_guild=True) + async def removecategory(self, interaction: discord.Interaction, name: str): + cfg.remove_category(interaction.guild_id, name) + await interaction.response.send_message( + f"Categorie **{name}** supprimee.", ephemeral=True + ) + + @modmail_group.command(name="enable", description="Activer le modmail") + @app_commands.checks.has_permissions(manage_guild=True) + async def enable(self, interaction: discord.Interaction): + cfg.set_enabled(interaction.guild_id, True) + await interaction.response.send_message("Modmail active.", ephemeral=True) + + @modmail_group.command(name="disable", description="Desactiver le modmail") + @app_commands.checks.has_permissions(manage_guild=True) + async def disable(self, interaction: discord.Interaction): + cfg.set_enabled(interaction.guild_id, False) + await interaction.response.send_message("Modmail desactive.", 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: + try: + await dm_user.send( + "Ton ticket a ete ferme. Voici le transcript:", + file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"), + ) + except discord.HTTPException: + pass + + 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) + + try: + await channel.delete(reason=f"Ticket ferme par {user}") + except discord.HTTPException: + pass + + +async def setup(bot: commands.Bot): + await bot.add_cog(ModMail(bot)) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/modmail_config.py b/utils/modmail_config.py new file mode 100644 index 0000000..035a6e1 --- /dev/null +++ b/utils/modmail_config.py @@ -0,0 +1,110 @@ +import json +from pathlib import Path + +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_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 new file mode 100644 index 0000000..38185bc --- /dev/null +++ b/utils/modmail_transcript.py @@ -0,0 +1,212 @@ +import html +import os +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)