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
This commit is contained in:
parent
f0e93d6262
commit
f505b6c552
5 changed files with 837 additions and 0 deletions
1
bot.py
1
bot.py
|
|
@ -25,6 +25,7 @@ class VisionBot(commands.Bot):
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
logger.info("Chargement des cogs...")
|
logger.info("Chargement des cogs...")
|
||||||
await self.load_extension("commandes.hello")
|
await self.load_extension("commandes.hello")
|
||||||
|
await self.load_extension("commandes.modmail")
|
||||||
|
|
||||||
if config.DEV and DEV_GUILD_IDS:
|
if config.DEV and DEV_GUILD_IDS:
|
||||||
logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}")
|
logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}")
|
||||||
|
|
|
||||||
514
commandes/modmail.py
Normal file
514
commandes/modmail.py
Normal file
|
|
@ -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))
|
||||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
110
utils/modmail_config.py
Normal file
110
utils/modmail_config.py
Normal file
|
|
@ -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)
|
||||||
212
utils/modmail_transcript.py
Normal file
212
utils/modmail_transcript.py
Normal file
|
|
@ -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 = '<span class="badge badge-staff">STAFF</span>' 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'<div class="attachment"><img src="{html.escape(att.url)}" alt="attachment" /></div>'
|
||||||
|
else:
|
||||||
|
attachments_html += f'<div class="attachment"><a href="{html.escape(att.url)}">{html.escape(att.filename)}</a></div>'
|
||||||
|
|
||||||
|
messages_html += f"""
|
||||||
|
<div class="message {'staff' if is_staff else 'user'}">
|
||||||
|
<div class="msg-header">
|
||||||
|
<img class="avatar" src="{avatar}" alt="{html.escape(str(author))}" />
|
||||||
|
<span class="author">{html.escape(str(author))}</span>
|
||||||
|
{staff_badge}
|
||||||
|
<span class="timestamp">{timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{content}</div>
|
||||||
|
{attachments_html}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not messages_html:
|
||||||
|
messages_html = '<div class="empty">Aucun message dans ce ticket.</div>'
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Transcript — Ticket #{ticket_info.get('channel_id', 0)}</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background: #0b0d11;
|
||||||
|
color: #dcddde;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
background: linear-gradient(135deg, #5865f2, #7289da);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.header h1 {{
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}}
|
||||||
|
.header .meta {{
|
||||||
|
font-size: 14px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}}
|
||||||
|
.header .meta span {{
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.messages {{
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}}
|
||||||
|
.message {{
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}}
|
||||||
|
.message:hover {{
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}}
|
||||||
|
.message.staff {{
|
||||||
|
border-left: 3px solid #57f287;
|
||||||
|
background: rgba(87, 242, 135, 0.03);
|
||||||
|
}}
|
||||||
|
.message.user {{
|
||||||
|
border-left: 3px solid #5865f2;
|
||||||
|
}}
|
||||||
|
.msg-header {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.avatar {{
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}}
|
||||||
|
.author {{
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
.badge-staff {{
|
||||||
|
background: #57f287;
|
||||||
|
color: #000;
|
||||||
|
}}
|
||||||
|
.timestamp {{
|
||||||
|
font-size: 12px;
|
||||||
|
color: #72767d;
|
||||||
|
margin-left: auto;
|
||||||
|
}}
|
||||||
|
.msg-content {{
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #dcddde;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-left: 42px;
|
||||||
|
}}
|
||||||
|
.attachment {{
|
||||||
|
margin-left: 42px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}}
|
||||||
|
.attachment img {{
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2f3136;
|
||||||
|
}}
|
||||||
|
.attachment a {{
|
||||||
|
color: #00aff4;
|
||||||
|
text-decoration: none;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #72767d;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
color: #4f545c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #2f3136;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Transcript — Ticket #{ticket_info.get('channel_id', 0)}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Catégorie: {html.escape(ticket_info.get('category', 'N/A'))}</span>
|
||||||
|
<span>Utilisateur: {ticket_info.get('user_id', 'N/A')}</span>
|
||||||
|
<span>Généré le: {date_str}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="messages">
|
||||||
|
{messages_html}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Vision Studio — Transcript généré automatiquement
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue