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))