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