From b736f4cc8b4538d81000b39c77e83927379c7103 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 19 Sep 2026 15:30:07 +0200 Subject: [PATCH 1/9] refactor(modmail): centralize config into single /modmail config panel - Remove 5 redundant commands (setup, addcategory, removecategory, enable, disable) - Single /modmail config opens interactive Components V2 panel - Channel select for ticket category, role select for staff, channel select for logs - Toggle enable/disable button - Categories sub-panel: add via modal, remove via select - Fix lint (unused imports/vars, contextlib.suppress) + ruff format --- bot.py | 1 + commandes/modmail.py | 378 ++++++++++++++++++++++-------------- config.py | 1 + utils/modmail_config.py | 7 +- utils/modmail_transcript.py | 19 +- vision.py | 2 - 6 files changed, 251 insertions(+), 157 deletions(-) diff --git a/bot.py b/bot.py index cb69a35..25b2592 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ import logging import os + import discord from discord.ext import commands diff --git a/commandes/modmail.py b/commandes/modmail.py index bb9191e..5872a86 100644 --- a/commandes/modmail.py +++ b/commandes/modmail.py @@ -1,7 +1,8 @@ +import contextlib + 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 @@ -45,9 +46,7 @@ class CategorySelect(discord.ui.Select): ) return - await interaction.response.send_modal( - TicketModal(category_key, cat.get("name", category_key)) - ) + await interaction.response.send_modal(TicketModal(category_key, cat.get("name", category_key))) class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): @@ -106,13 +105,11 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): await _send_ticket_panel(ticket_channel, interaction.user, self.category_name, self.reason.value) - try: + with contextlib.suppress(discord.HTTPException): 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: @@ -181,6 +178,204 @@ class PersistentQuickOpen(discord.ui.View): 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.""" @@ -221,7 +416,6 @@ class ModMail(commands.Cog): 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.") @@ -233,10 +427,7 @@ class ModMail(commands.Cog): 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']}**." - ) + discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.") ) open_button = discord.ui.Button( label=f"Ouvrir β€” {cat['name']}", @@ -252,10 +443,7 @@ class ModMail(commands.Cog): 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." - ) + 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)) @@ -266,7 +454,6 @@ class ModMail(commands.Cog): 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 @@ -279,15 +466,17 @@ class ModMail(commands.Cog): 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, - }) + self._dm_messages[user_id].append( + { + "author": message.author, + "content": message.content, + "timestamp": message.created_at, + "attachments": list(message.attachments), + "is_staff": True, + } + ) - try: + with contextlib.suppress(discord.HTTPException): embed = discord.Embed( description=message.content or "*Pieces jointes*", color=discord.Color.green(), @@ -299,26 +488,24 @@ class ModMail(commands.Cog): ) 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: + with contextlib.suppress(discord.HTTPException): 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, - }) + 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*", @@ -337,112 +524,17 @@ class ModMail(commands.Cog): # --- COMMANDES --- - modmail_group = app_commands.Group(name="modmail", description="Configuration du modmail") + modmail_group = app_commands.Group(name="modmail", description="Systeme de 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") + @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): - 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) + cfg.set_guild_id(interaction.guild_id) await interaction.response.send_message( - f"Categorie **{name}** ajoutee.", ephemeral=True + view=ModmailConfigView(interaction.guild), + 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") @@ -475,13 +567,11 @@ class ModMail(commands.Cog): dm_user = self.bot.get_user(user_id) if dm_user: - try: + 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"), ) - 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 @@ -496,18 +586,14 @@ class ModMail(commands.Cog): 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}" - ) + 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: + with contextlib.suppress(discord.HTTPException): await channel.delete(reason=f"Ticket ferme par {user}") - except discord.HTTPException: - pass async def setup(bot: commands.Bot): diff --git a/config.py b/config.py index ea17f0c..1a62ff6 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,6 @@ import os from pathlib import Path + from dotenv import load_dotenv load_dotenv() diff --git a/utils/modmail_config.py b/utils/modmail_config.py index 035a6e1..b840cc4 100644 --- a/utils/modmail_config.py +++ b/utils/modmail_config.py @@ -1,5 +1,4 @@ import json -from pathlib import Path from config import DATA_DIR @@ -32,6 +31,12 @@ 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 diff --git a/utils/modmail_transcript.py b/utils/modmail_transcript.py index 38185bc..f5d4a98 100644 --- a/utils/modmail_transcript.py +++ b/utils/modmail_transcript.py @@ -1,5 +1,4 @@ import html -import os from datetime import datetime, timezone @@ -35,12 +34,16 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str: attachments_html = "" for att in msg.get("attachments", []): if att.url.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): - attachments_html += f'
attachment
' + attachments_html += ( + f'
attachment
' + ) else: - attachments_html += f'
{html.escape(att.filename)}
' + attachments_html += ( + f'
{html.escape(att.filename)}
' + ) messages_html += f""" -
+
{html.escape(str(author))} {html.escape(str(author))} @@ -60,7 +63,7 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str: -Transcript β€” Ticket #{ticket_info.get('channel_id', 0)} +Transcript β€” Ticket #{ticket_info.get("channel_id", 0)}