VisionApp/commandes/modmail.py
Mathis eec6b2bb9b
All checks were successful
deploy / deploy (push) Successful in 21s
fix(modmail): use Button subclasses with callbacks in LayoutView
Raw discord.ui.Button in LayoutView has no callback -> discord.py
can't dispatch the interaction -> 3s timeout.
Replace with CloseTicketButton/OpenTicketButton subclasses that have
proper callback methods. Remove PersistentCloseButton/PersistentQuickOpen.
2026-09-20 19:27:04 +02:00

604 lines
22 KiB
Python

import contextlib
import logging
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):
try:
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)))
except Exception as e:
logging.getLogger("vision").error(f"CategorySelect callback error: {e}", exc_info=True)
if not interaction.response.is_done():
await interaction.response.send_message(f"Erreur interne: `{e}`", ephemeral=True)
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())
container.add_item(discord.ui.ActionRow(CloseTicketButton()))
view = discord.ui.LayoutView()
view.add_item(container)
await channel.send(view=view)
class CloseTicketButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Fermer le ticket",
style=discord.ButtonStyle.danger,
custom_id="modmail_close_ticket",
)
async def callback(self, interaction: discord.Interaction):
cog = interaction.client.get_cog("ModMail")
if cog:
await cog._close_ticket(interaction.channel, interaction.user)
class OpenTicketButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Ouvrir le ticket",
style=discord.ButtonStyle.primary,
custom_id="modmail_quick_open",
)
async def callback(self, interaction: discord.Interaction):
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]] = {}
# --- 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']}**.")
)
container.add_item(discord.ui.ActionRow(OpenTicketButton()))
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))