Merge branch 'dev' into main — ticket system v2
All checks were successful
deploy / deploy (push) Successful in 22s
All checks were successful
deploy / deploy (push) Successful in 22s
This commit is contained in:
commit
33b9303948
15 changed files with 2069 additions and 952 deletions
2
bot.py
2
bot.py
|
|
@ -26,7 +26,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")
|
await self.load_extension("tickets.cog")
|
||||||
|
|
||||||
self.tree.on_error = self.on_app_command_error
|
self.tree.on_error = self.on_app_command_error
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,620 +0,0 @@
|
||||||
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)
|
|
||||||
if not cat:
|
|
||||||
await interaction.response.send_message("Cette categorie n'existe plus.", ephemeral=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
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()
|
|
||||||
if self.category_key not in cfg.get_categories():
|
|
||||||
await interaction.followup.send("La categorie n'existe plus. Reessayez.", ephemeral=True)
|
|
||||||
return
|
|
||||||
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
|
|
||||||
|
|
||||||
categories = cfg.get_categories()
|
|
||||||
if not categories:
|
|
||||||
with contextlib.suppress(discord.HTTPException):
|
|
||||||
await message.author.send(
|
|
||||||
"Le systeme de tickets n'est pas encore configure. "
|
|
||||||
"Un administrateur doit d'abord configurer les categories."
|
|
||||||
)
|
|
||||||
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))
|
|
||||||
244
tests/test_data.py
Normal file
244
tests/test_data.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tickets.data import (
|
||||||
|
GuildTicketConfig,
|
||||||
|
TicketCategory,
|
||||||
|
TicketData,
|
||||||
|
TicketMessage,
|
||||||
|
TicketPriority,
|
||||||
|
TicketStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketStatus:
|
||||||
|
def test_open_value(self):
|
||||||
|
assert TicketStatus.OPEN.value == "open"
|
||||||
|
|
||||||
|
def test_closed_value(self):
|
||||||
|
assert TicketStatus.CLOSED.value == "closed"
|
||||||
|
|
||||||
|
def test_enum_inheritance(self):
|
||||||
|
assert issubclass(TicketStatus, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketPriority:
|
||||||
|
def test_all_values(self):
|
||||||
|
assert TicketPriority.LOW.value == "low"
|
||||||
|
assert TicketPriority.NORMAL.value == "normal"
|
||||||
|
assert TicketPriority.HIGH.value == "high"
|
||||||
|
assert TicketPriority.URGENT.value == "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketCategory:
|
||||||
|
def test_create_default(self):
|
||||||
|
cat = TicketCategory(id="test", name="Test")
|
||||||
|
assert cat.id == "test"
|
||||||
|
assert cat.name == "Test"
|
||||||
|
assert cat.emoji == ""
|
||||||
|
assert cat.description == ""
|
||||||
|
assert cat.staff_role_id is None
|
||||||
|
assert cat.discord_category_id is None
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
cat = TicketCategory(
|
||||||
|
id="support",
|
||||||
|
name="Support",
|
||||||
|
emoji="🎫",
|
||||||
|
description="Aide",
|
||||||
|
staff_role_id=123,
|
||||||
|
discord_category_id=456,
|
||||||
|
)
|
||||||
|
d = cat.to_dict()
|
||||||
|
assert d["id"] == "support"
|
||||||
|
assert d["name"] == "Support"
|
||||||
|
assert d["emoji"] == "🎫"
|
||||||
|
assert d["description"] == "Aide"
|
||||||
|
assert d["staff_role_id"] == 123
|
||||||
|
assert d["discord_category_id"] == 456
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
d = {
|
||||||
|
"id": "support",
|
||||||
|
"name": "Support",
|
||||||
|
"emoji": "🎫",
|
||||||
|
"description": "Aide",
|
||||||
|
"staff_role_id": 123,
|
||||||
|
"discord_category_id": 456,
|
||||||
|
}
|
||||||
|
cat = TicketCategory.from_dict(d)
|
||||||
|
assert cat.id == "support"
|
||||||
|
assert cat.name == "Support"
|
||||||
|
assert cat.staff_role_id == 123
|
||||||
|
|
||||||
|
def test_from_dict_ignores_extra_keys(self):
|
||||||
|
d = {"id": "x", "name": "X", "unknown_key": "should be ignored"}
|
||||||
|
cat = TicketCategory.from_dict(d)
|
||||||
|
assert cat.id == "x"
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
cat = TicketCategory(id="a", name="A", emoji="🔧", description="desc")
|
||||||
|
cat2 = TicketCategory.from_dict(cat.to_dict())
|
||||||
|
assert cat2 == cat
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketMessage:
|
||||||
|
def test_create_minimal(self):
|
||||||
|
msg = TicketMessage(author_id=1, author_name="user", content="hello", timestamp="2026-01-01")
|
||||||
|
assert msg.attachments == []
|
||||||
|
assert msg.is_staff is False
|
||||||
|
|
||||||
|
def test_create_full(self):
|
||||||
|
msg = TicketMessage(
|
||||||
|
author_id=1,
|
||||||
|
author_name="user",
|
||||||
|
content="hello",
|
||||||
|
timestamp="2026-01-01",
|
||||||
|
attachments=["a.png"],
|
||||||
|
is_staff=True,
|
||||||
|
)
|
||||||
|
assert msg.attachments == ["a.png"]
|
||||||
|
assert msg.is_staff is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketData:
|
||||||
|
def test_create_minimal(self):
|
||||||
|
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="cat")
|
||||||
|
assert t.status == TicketStatus.OPEN
|
||||||
|
assert t.priority == TicketPriority.NORMAL
|
||||||
|
assert t.locked is False
|
||||||
|
assert t.claimed_by is None
|
||||||
|
assert t.messages == []
|
||||||
|
assert t.reason == ""
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
t = TicketData(
|
||||||
|
channel_id=100,
|
||||||
|
guild_id=200,
|
||||||
|
user_id=300,
|
||||||
|
category_id="support",
|
||||||
|
status=TicketStatus.CLOSED,
|
||||||
|
priority=TicketPriority.HIGH,
|
||||||
|
claimed_by=999,
|
||||||
|
locked=True,
|
||||||
|
reason="test",
|
||||||
|
)
|
||||||
|
d = t.to_dict()
|
||||||
|
assert d["channel_id"] == 100
|
||||||
|
assert d["status"] == "closed"
|
||||||
|
assert d["priority"] == "high"
|
||||||
|
assert d["claimed_by"] == 999
|
||||||
|
assert d["locked"] is True
|
||||||
|
assert d["reason"] == "test"
|
||||||
|
|
||||||
|
def test_to_dict_with_messages(self):
|
||||||
|
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="c")
|
||||||
|
t.messages.append(TicketMessage(author_id=1, author_name="a", content="hi", timestamp="t"))
|
||||||
|
d = t.to_dict()
|
||||||
|
assert len(d["messages"]) == 1
|
||||||
|
assert d["messages"][0]["content"] == "hi"
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
d = {
|
||||||
|
"channel_id": 1,
|
||||||
|
"guild_id": 2,
|
||||||
|
"user_id": 3,
|
||||||
|
"category_id": "c",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"claimed_by": 99,
|
||||||
|
"locked": True,
|
||||||
|
"reason": "r",
|
||||||
|
"messages": [{"author_id": 1, "author_name": "a", "content": "hi", "timestamp": "t"}],
|
||||||
|
}
|
||||||
|
t = TicketData.from_dict(d)
|
||||||
|
assert t.status == TicketStatus.CLOSED
|
||||||
|
assert t.priority == TicketPriority.HIGH
|
||||||
|
assert t.claimed_by == 99
|
||||||
|
assert t.locked is True
|
||||||
|
assert len(t.messages) == 1
|
||||||
|
|
||||||
|
def test_from_dict_legacy_keys(self):
|
||||||
|
d = {
|
||||||
|
"channel_id": 1,
|
||||||
|
"guild_id": 2,
|
||||||
|
"user_id": 3,
|
||||||
|
"category_id": "c",
|
||||||
|
"transcript_path": "/old/path.html",
|
||||||
|
"rating": 5,
|
||||||
|
"feedback": "ok",
|
||||||
|
}
|
||||||
|
t = TicketData.from_dict(d)
|
||||||
|
assert not hasattr(t, "transcript_path")
|
||||||
|
assert not hasattr(t, "rating")
|
||||||
|
assert not hasattr(t, "feedback")
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
t = TicketData(
|
||||||
|
channel_id=10,
|
||||||
|
guild_id=20,
|
||||||
|
user_id=30,
|
||||||
|
category_id="cat",
|
||||||
|
status=TicketStatus.CLOSED,
|
||||||
|
priority=TicketPriority.URGENT,
|
||||||
|
claimed_by=50,
|
||||||
|
locked=True,
|
||||||
|
reason="test reason",
|
||||||
|
created_at="2026-01-01T00:00:00",
|
||||||
|
closed_at="2026-01-02T00:00:00",
|
||||||
|
)
|
||||||
|
t.messages.append(TicketMessage(author_id=1, author_name="u", content="m", timestamp="t"))
|
||||||
|
t2 = TicketData.from_dict(t.to_dict())
|
||||||
|
assert t2.channel_id == t.channel_id
|
||||||
|
assert t2.status == t.status
|
||||||
|
assert t2.priority == t.priority
|
||||||
|
assert t2.claimed_by == t.claimed_by
|
||||||
|
assert t2.locked == t.locked
|
||||||
|
assert len(t2.messages) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuildTicketConfig:
|
||||||
|
def test_create_default(self):
|
||||||
|
c = GuildTicketConfig(guild_id=1)
|
||||||
|
assert c.enabled is False
|
||||||
|
assert c.categories == []
|
||||||
|
assert c.staff_role_id is None
|
||||||
|
assert c.log_channel_id is None
|
||||||
|
assert c.transcript_enabled is True
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
c = GuildTicketConfig(
|
||||||
|
guild_id=1,
|
||||||
|
enabled=True,
|
||||||
|
staff_role_id=100,
|
||||||
|
log_channel_id=200,
|
||||||
|
panel_channel_id=300,
|
||||||
|
panel_message_id=400,
|
||||||
|
)
|
||||||
|
c.categories.append(TicketCategory(id="a", name="A"))
|
||||||
|
d = c.to_dict()
|
||||||
|
assert d["guild_id"] == 1
|
||||||
|
assert d["enabled"] is True
|
||||||
|
assert d["staff_role_id"] == 100
|
||||||
|
assert d["log_channel_id"] == 200
|
||||||
|
assert len(d["categories"]) == 1
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
d = {
|
||||||
|
"guild_id": 1,
|
||||||
|
"enabled": True,
|
||||||
|
"staff_role_id": 100,
|
||||||
|
"log_channel_id": 200,
|
||||||
|
"categories": [{"id": "a", "name": "A"}],
|
||||||
|
}
|
||||||
|
c = GuildTicketConfig.from_dict(d)
|
||||||
|
assert c.enabled is True
|
||||||
|
assert len(c.categories) == 1
|
||||||
|
assert c.categories[0].id == "a"
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
c = GuildTicketConfig(guild_id=42, enabled=True)
|
||||||
|
c.categories.append(TicketCategory(id="x", name="X", emoji="🔧"))
|
||||||
|
c2 = GuildTicketConfig.from_dict(c.to_dict())
|
||||||
|
assert c2.guild_id == 42
|
||||||
|
assert c2.enabled is True
|
||||||
|
assert c2.categories[0].id == "x"
|
||||||
130
tests/test_storage.py
Normal file
130
tests/test_storage.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tickets import storage
|
||||||
|
from tickets.data import GuildTicketConfig, TicketCategory, TicketData
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_storage(tmp_path):
|
||||||
|
"""Isolate storage in a temp dir and reset caches."""
|
||||||
|
storage.TICKETS_DIR = tmp_path / "tickets"
|
||||||
|
storage._CONFIG_FILE = storage.TICKETS_DIR / "config.json"
|
||||||
|
storage._config_cache.clear()
|
||||||
|
storage._tickets_cache.clear()
|
||||||
|
yield
|
||||||
|
storage._config_cache.clear()
|
||||||
|
storage._tickets_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConfig:
|
||||||
|
def test_returns_default_when_missing(self):
|
||||||
|
config = storage.get_config(guild_id=123)
|
||||||
|
assert config.guild_id == 123
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.categories == []
|
||||||
|
|
||||||
|
def test_returns_same_object(self):
|
||||||
|
c1 = storage.get_config(guild_id=1)
|
||||||
|
c2 = storage.get_config(guild_id=1)
|
||||||
|
assert c1 is c2
|
||||||
|
|
||||||
|
def test_different_guilds_different_configs(self):
|
||||||
|
c1 = storage.get_config(guild_id=1)
|
||||||
|
c2 = storage.get_config(guild_id=2)
|
||||||
|
assert c1.guild_id != c2.guild_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveConfig:
|
||||||
|
def test_save_creates_file(self, tmp_path):
|
||||||
|
config = storage.get_config(guild_id=1)
|
||||||
|
config.enabled = True
|
||||||
|
storage.save_config(config)
|
||||||
|
assert storage._CONFIG_FILE.exists()
|
||||||
|
|
||||||
|
def test_save_persists(self, tmp_path):
|
||||||
|
config = storage.get_config(guild_id=1)
|
||||||
|
config.enabled = True
|
||||||
|
config.categories.append(TicketCategory(id="a", name="A"))
|
||||||
|
storage.save_config(config)
|
||||||
|
|
||||||
|
storage._config_cache.clear()
|
||||||
|
loaded = storage.get_config(guild_id=1)
|
||||||
|
assert loaded.enabled is True
|
||||||
|
assert len(loaded.categories) == 1
|
||||||
|
assert loaded.categories[0].id == "a"
|
||||||
|
|
||||||
|
def test_save_multiple_guilds(self):
|
||||||
|
c1 = storage.get_config(guild_id=1)
|
||||||
|
c1.enabled = True
|
||||||
|
storage.save_config(c1)
|
||||||
|
|
||||||
|
c2 = storage.get_config(guild_id=2)
|
||||||
|
c2.enabled = False
|
||||||
|
storage.save_config(c2)
|
||||||
|
|
||||||
|
storage._config_cache.clear()
|
||||||
|
assert storage.get_config(guild_id=1).enabled is True
|
||||||
|
assert storage.get_config(guild_id=2).enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveAndGetTicket:
|
||||||
|
def test_get_missing_returns_none(self):
|
||||||
|
assert storage.get_ticket(999) is None
|
||||||
|
|
||||||
|
def test_save_and_get(self):
|
||||||
|
ticket = TicketData(channel_id=100, guild_id=1, user_id=2, category_id="cat")
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
loaded = storage.get_ticket(100)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.channel_id == 100
|
||||||
|
assert loaded.category_id == "cat"
|
||||||
|
|
||||||
|
def test_save_persists_to_disk(self):
|
||||||
|
ticket = TicketData(channel_id=200, guild_id=1, user_id=2, category_id="c")
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
storage._tickets_cache.clear()
|
||||||
|
loaded = storage.get_ticket(200)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.user_id == 2
|
||||||
|
|
||||||
|
def test_save_overwrites(self):
|
||||||
|
t1 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="old")
|
||||||
|
storage.save_ticket(t1)
|
||||||
|
|
||||||
|
t2 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="new")
|
||||||
|
storage.save_ticket(t2)
|
||||||
|
|
||||||
|
loaded = storage.get_ticket(300)
|
||||||
|
assert loaded.category_id == "new"
|
||||||
|
|
||||||
|
def test_multiple_tickets(self):
|
||||||
|
t1 = TicketData(channel_id=400, guild_id=1, user_id=2, category_id="a")
|
||||||
|
t2 = TicketData(channel_id=401, guild_id=1, user_id=3, category_id="b")
|
||||||
|
storage.save_ticket(t1)
|
||||||
|
storage.save_ticket(t2)
|
||||||
|
assert storage.get_ticket(400).category_id == "a"
|
||||||
|
assert storage.get_ticket(401).category_id == "b"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteTicket:
|
||||||
|
def test_delete_removes_file(self):
|
||||||
|
ticket = TicketData(channel_id=500, guild_id=1, user_id=2, category_id="c")
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
assert storage.get_ticket(500) is not None
|
||||||
|
|
||||||
|
storage.delete_ticket(500)
|
||||||
|
assert storage.get_ticket(500) is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self):
|
||||||
|
storage.delete_ticket(9999)
|
||||||
|
|
||||||
|
def test_delete_clears_cache(self):
|
||||||
|
ticket = TicketData(channel_id=600, guild_id=1, user_id=2, category_id="c")
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
storage.delete_ticket(600)
|
||||||
|
assert 600 not in storage._tickets_cache
|
||||||
133
tests/test_transcript.py
Normal file
133
tests/test_transcript.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tickets.data import TicketData, TicketMessage
|
||||||
|
from tickets.transcript import _format_ts, generate_transcript
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatTs:
|
||||||
|
def test_valid_iso(self):
|
||||||
|
result = _format_ts("2026-01-15T10:30:00+00:00")
|
||||||
|
assert result == "15/01/2026 10:30:00"
|
||||||
|
|
||||||
|
def test_valid_naive_iso(self):
|
||||||
|
result = _format_ts("2026-06-01T12:00:00")
|
||||||
|
assert result == "01/06/2026 12:00:00"
|
||||||
|
|
||||||
|
def test_invalid_string_returns_as_is(self):
|
||||||
|
assert _format_ts("not-a-date") == "not-a-date"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert _format_ts("") == ""
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
assert _format_ts(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ticket(**kwargs) -> TicketData:
|
||||||
|
defaults = dict(channel_id=123, guild_id=456, user_id=789, category_id="support")
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return TicketData(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateTranscript:
|
||||||
|
def test_returns_html(self):
|
||||||
|
result = generate_transcript(_make_ticket())
|
||||||
|
assert result.startswith("<!DOCTYPE html>")
|
||||||
|
assert "</html>" in result
|
||||||
|
|
||||||
|
def test_contains_channel_id(self):
|
||||||
|
result = generate_transcript(_make_ticket(channel_id=42))
|
||||||
|
assert "Ticket #42" in result
|
||||||
|
|
||||||
|
def test_empty_messages(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "Aucun message" in result
|
||||||
|
|
||||||
|
def test_single_user_message(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(author_id=1, author_name="TestUser", content="Hello!", timestamp="2026-01-01T00:00:00")
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "TestUser" in result
|
||||||
|
assert "Hello!" in result
|
||||||
|
assert 'class="badge badge-staff"' not in result
|
||||||
|
|
||||||
|
def test_staff_message_has_badge(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(
|
||||||
|
author_id=2, author_name="StaffUser", content="Reply", timestamp="2026-01-01T00:00:00", is_staff=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "badge-staff" in result
|
||||||
|
assert "STAFF" in result
|
||||||
|
|
||||||
|
def test_message_content_escaped(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(author_id=1, author_name="x", content="<script>alert('xss')</script>", timestamp="t")
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "<script>" not in result
|
||||||
|
assert "<script>" in result
|
||||||
|
|
||||||
|
def test_image_attachment(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(
|
||||||
|
author_id=1,
|
||||||
|
author_name="u",
|
||||||
|
content="pic",
|
||||||
|
timestamp="t",
|
||||||
|
attachments=["https://example.com/image.png"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "<img" in result
|
||||||
|
assert "image.png" in result
|
||||||
|
|
||||||
|
def test_file_attachment(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(
|
||||||
|
author_id=1,
|
||||||
|
author_name="u",
|
||||||
|
content="file",
|
||||||
|
timestamp="t",
|
||||||
|
attachments=["https://example.com/doc.pdf"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "<a href=" in result
|
||||||
|
assert "doc.pdf" in result
|
||||||
|
|
||||||
|
def test_closed_ticket_shows_closed_at(self):
|
||||||
|
ticket = _make_ticket(closed_at="2026-01-02T10:00:00+00:00")
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "Ferme" in result or "Fermé" in result
|
||||||
|
|
||||||
|
def test_open_ticket_no_closed_at(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
assert "Ferme" not in result
|
||||||
|
|
||||||
|
def test_dark_theme_colors(self):
|
||||||
|
result = generate_transcript(_make_ticket())
|
||||||
|
assert "#0b0d11" in result
|
||||||
|
|
||||||
|
def test_multiple_messages(self):
|
||||||
|
ticket = _make_ticket()
|
||||||
|
for i in range(5):
|
||||||
|
ticket.messages.append(
|
||||||
|
TicketMessage(author_id=i, author_name=f"User{i}", content=f"Message {i}", timestamp=f"t{i}")
|
||||||
|
)
|
||||||
|
result = generate_transcript(ticket)
|
||||||
|
for i in range(5):
|
||||||
|
assert f"Message {i}" in result
|
||||||
|
|
||||||
|
def test_footer_text(self):
|
||||||
|
result = generate_transcript(_make_ticket())
|
||||||
|
assert "Vision Studio" in result
|
||||||
304
tests/test_ui.py
Normal file
304
tests/test_ui.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tickets import storage
|
||||||
|
from tickets.cog import ConfigToggleButton
|
||||||
|
from tickets.data import GuildTicketConfig, TicketCategory, TicketData, TicketStatus
|
||||||
|
from tickets.ui import (
|
||||||
|
AddMemberButton,
|
||||||
|
ClaimButton,
|
||||||
|
CloseButton,
|
||||||
|
LockButton,
|
||||||
|
PanelCreateButton,
|
||||||
|
TransferButton,
|
||||||
|
_is_staff,
|
||||||
|
_now_iso,
|
||||||
|
_send_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
STAFF_ROLE_ID = 999999
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_storage():
|
||||||
|
storage._config_cache.clear()
|
||||||
|
storage._tickets_cache.clear()
|
||||||
|
config = GuildTicketConfig(guild_id=999)
|
||||||
|
config.staff_role_id = STAFF_ROLE_ID
|
||||||
|
storage._config_cache[999] = config
|
||||||
|
yield
|
||||||
|
storage._config_cache.clear()
|
||||||
|
storage._tickets_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_member(is_admin=False, has_staff_role=True):
|
||||||
|
member = MagicMock(spec=discord.Member)
|
||||||
|
member.guild_permissions.administrator = is_admin
|
||||||
|
if has_staff_role:
|
||||||
|
member.roles = [MagicMock(id=STAFF_ROLE_ID)]
|
||||||
|
else:
|
||||||
|
member.roles = []
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_interaction(guild_id=999, user_id=1, channel_id=123, member=None):
|
||||||
|
if member is None:
|
||||||
|
member = _make_member()
|
||||||
|
interaction = MagicMock(spec=discord.Interaction)
|
||||||
|
interaction.guild_id = guild_id
|
||||||
|
interaction.channel_id = channel_id
|
||||||
|
interaction.channel = MagicMock()
|
||||||
|
interaction.channel.id = channel_id
|
||||||
|
interaction.user = MagicMock()
|
||||||
|
interaction.user.id = user_id
|
||||||
|
interaction.user.mention = f"<@{user_id}>"
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.guild = MagicMock(spec=discord.Guild)
|
||||||
|
interaction.guild.id = guild_id
|
||||||
|
interaction.guild.get_member.return_value = member
|
||||||
|
interaction.channel.overwrites_for = MagicMock()
|
||||||
|
interaction.channel.set_permissions = AsyncMock()
|
||||||
|
interaction.channel.delete = AsyncMock()
|
||||||
|
interaction.channel.send = AsyncMock()
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
|
||||||
|
def _save_ticket(channel_id=123, **kwargs):
|
||||||
|
defaults = dict(channel_id=channel_id, guild_id=999, user_id=1, category_id="cat")
|
||||||
|
defaults.update(kwargs)
|
||||||
|
ticket = TicketData(**defaults)
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsStaff:
|
||||||
|
def test_admin(self):
|
||||||
|
member = _make_member(is_admin=True)
|
||||||
|
config = storage.get_config(999)
|
||||||
|
assert _is_staff(member, config) is True
|
||||||
|
|
||||||
|
def test_staff_role_match(self):
|
||||||
|
member = _make_member(has_staff_role=True)
|
||||||
|
config = storage.get_config(999)
|
||||||
|
assert _is_staff(member, config) is True
|
||||||
|
|
||||||
|
def test_not_staff(self):
|
||||||
|
member = _make_member(has_staff_role=False)
|
||||||
|
config = storage.get_config(999)
|
||||||
|
assert _is_staff(member, config) is False
|
||||||
|
|
||||||
|
def test_category_staff_role(self):
|
||||||
|
member = _make_member(has_staff_role=False)
|
||||||
|
member.roles = [MagicMock(id=77777)]
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.categories = [TicketCategory(id="c", name="C", staff_role_id=77777)]
|
||||||
|
assert _is_staff(member, config) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNowIso:
|
||||||
|
def test_returns_iso_string(self):
|
||||||
|
result = _now_iso()
|
||||||
|
assert "T" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendLog:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_log_channel(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
guild = MagicMock()
|
||||||
|
guild.get_channel.return_value = None
|
||||||
|
await _send_log(guild, config, MagicMock())
|
||||||
|
guild.get_channel.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_to_log_channel(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.log_channel_id = 555
|
||||||
|
channel = AsyncMock()
|
||||||
|
guild = MagicMock()
|
||||||
|
guild.get_channel.return_value = channel
|
||||||
|
embed = MagicMock()
|
||||||
|
await _send_log(guild, config, embed)
|
||||||
|
channel.send.assert_called_once_with(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPanelCreateButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_system(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.enabled = False
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await PanelCreateButton().callback(interaction)
|
||||||
|
interaction.response.send_message.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_categories(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.enabled = True
|
||||||
|
config.categories = []
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await PanelCreateButton().callback(interaction)
|
||||||
|
interaction.response.send_message.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_category_opens_modal(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.enabled = True
|
||||||
|
config.categories = [TicketCategory(id="c1", name="Cat1")]
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await PanelCreateButton().callback(interaction)
|
||||||
|
interaction.response.send_modal.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_categories_sends_select(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.enabled = True
|
||||||
|
config.categories = [
|
||||||
|
TicketCategory(id="c1", name="Cat1"),
|
||||||
|
TicketCategory(id="c2", name="Cat2"),
|
||||||
|
]
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await PanelCreateButton().callback(interaction)
|
||||||
|
call_kwargs = interaction.response.send_message.call_args.kwargs
|
||||||
|
assert "view" in call_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaimButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_a_ticket(self):
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await ClaimButton().callback(interaction)
|
||||||
|
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_staff(self):
|
||||||
|
_save_ticket()
|
||||||
|
member = _make_member(is_admin=False, has_staff_role=False)
|
||||||
|
interaction = _mock_interaction(member=member)
|
||||||
|
await ClaimButton().callback(interaction)
|
||||||
|
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_claim_success(self):
|
||||||
|
_save_ticket()
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await ClaimButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).claimed_by == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unclaim_own(self):
|
||||||
|
_save_ticket(claimed_by=1)
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await ClaimButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).claimed_by is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_already_claimed_by_other(self):
|
||||||
|
_save_ticket(claimed_by=42)
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await ClaimButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).claimed_by == 42
|
||||||
|
|
||||||
|
|
||||||
|
class TestLockButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_a_ticket(self):
|
||||||
|
interaction = _mock_interaction(channel_id=99999)
|
||||||
|
await LockButton().callback(interaction)
|
||||||
|
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lock_toggle(self):
|
||||||
|
_save_ticket(locked=False)
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
interaction.channel.overwrites_for.return_value = MagicMock()
|
||||||
|
await LockButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).locked is True
|
||||||
|
interaction.channel.set_permissions.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unlock_toggle(self):
|
||||||
|
_save_ticket(locked=True)
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
interaction.channel.overwrites_for.return_value = MagicMock()
|
||||||
|
await LockButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).locked is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCloseButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_a_ticket(self):
|
||||||
|
interaction = _mock_interaction(channel_id=99999)
|
||||||
|
await CloseButton().callback(interaction)
|
||||||
|
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_staff(self):
|
||||||
|
_save_ticket()
|
||||||
|
member = _make_member(is_admin=False, has_staff_role=False)
|
||||||
|
interaction = _mock_interaction(member=member)
|
||||||
|
await CloseButton().callback(interaction)
|
||||||
|
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_success(self):
|
||||||
|
_save_ticket()
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await CloseButton().callback(interaction)
|
||||||
|
loaded = storage.get_ticket(123)
|
||||||
|
assert loaded.status == TicketStatus.CLOSED
|
||||||
|
assert loaded.closed_by == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_without_transcript(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
config.transcript_enabled = False
|
||||||
|
_save_ticket()
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await CloseButton().callback(interaction)
|
||||||
|
assert storage.get_ticket(123).status == TicketStatus.CLOSED
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransferButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_a_ticket(self):
|
||||||
|
interaction = _mock_interaction(channel_id=99999)
|
||||||
|
await TransferButton().callback(interaction)
|
||||||
|
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_user_select(self):
|
||||||
|
_save_ticket()
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await TransferButton().callback(interaction)
|
||||||
|
assert "view" in interaction.response.send_message.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddMemberButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_a_ticket(self):
|
||||||
|
interaction = _mock_interaction(channel_id=99999)
|
||||||
|
await AddMemberButton().callback(interaction)
|
||||||
|
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_user_select(self):
|
||||||
|
_save_ticket()
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await AddMemberButton().callback(interaction)
|
||||||
|
assert "view" in interaction.response.send_message.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigToggleButton:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle(self):
|
||||||
|
config = storage.get_config(999)
|
||||||
|
initial = config.enabled
|
||||||
|
interaction = _mock_interaction()
|
||||||
|
await ConfigToggleButton(enabled=initial).callback(interaction)
|
||||||
|
assert config.enabled != initial
|
||||||
0
tickets/__init__.py
Normal file
0
tickets/__init__.py
Normal file
243
tickets/cog.py
Normal file
243
tickets/cog.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from tickets import storage
|
||||||
|
from tickets.data import TicketCategory
|
||||||
|
from tickets.ui import (
|
||||||
|
AddMemberButton,
|
||||||
|
ClaimButton,
|
||||||
|
CloseButton,
|
||||||
|
LockButton,
|
||||||
|
PanelCreateButton,
|
||||||
|
TransferButton,
|
||||||
|
_is_staff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("vision")
|
||||||
|
|
||||||
|
|
||||||
|
class PanelView(discord.ui.LayoutView):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.TextDisplay(
|
||||||
|
"## Système de tickets\n"
|
||||||
|
"Besoin d'aide ? Créez un ticket et notre équipe vous répondra.\n"
|
||||||
|
"Cliquez sur le bouton ci-dessous pour commencer."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(discord.ui.ActionRow(PanelCreateButton()))
|
||||||
|
self.add_item(container)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigView(discord.ui.LayoutView):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay("## Configuration Tickets\nModifications enregistrées."))
|
||||||
|
self.add_item(container)
|
||||||
|
|
||||||
|
|
||||||
|
class AddCategoryModal(discord.ui.Modal, title="Ajouter une catégorie"):
|
||||||
|
name = discord.ui.TextInput(label="Nom", max_length=50, required=True)
|
||||||
|
emoji = discord.ui.TextInput(label="Emoji", max_length=10, 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):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
cat_id = self.name.value.lower().replace(" ", "-")[:20]
|
||||||
|
cat = TicketCategory(
|
||||||
|
id=cat_id,
|
||||||
|
name=self.name.value,
|
||||||
|
emoji=self.emoji.value or "",
|
||||||
|
description=self.description.value or "",
|
||||||
|
)
|
||||||
|
config.categories.append(cat)
|
||||||
|
storage.save_config(config)
|
||||||
|
await interaction.response.send_message(f"Catégorie **{cat.name}** ajoutée.", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigToggleButton(discord.ui.Button):
|
||||||
|
def __init__(self, enabled: bool):
|
||||||
|
super().__init__(
|
||||||
|
label="Désactiver" if enabled else "Activer",
|
||||||
|
style=discord.ButtonStyle.danger if enabled else discord.ButtonStyle.success,
|
||||||
|
custom_id="ticket_cfg_toggle",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
config.enabled = not config.enabled
|
||||||
|
storage.save_config(config)
|
||||||
|
await interaction.response.edit_message(view=ConfigView())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigAddCatButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(label="Ajouter catégorie", style=discord.ButtonStyle.primary, custom_id="ticket_cfg_addcat")
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
await interaction.response.send_modal(AddCategoryModal())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigSetStaffButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(label="Role staff", style=discord.ButtonStyle.secondary, custom_id="ticket_cfg_staff")
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
view = discord.ui.View(timeout=300)
|
||||||
|
sel = discord.ui.RoleSelect(
|
||||||
|
placeholder="Role staff...", custom_id="ticket_staff_role_select", min_values=1, max_values=1
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
config.staff_role_id = sel.values[0].id
|
||||||
|
storage.save_config(config)
|
||||||
|
await interaction.response.edit_message(content=f"Role staff : {sel.values[0].mention}", view=None)
|
||||||
|
|
||||||
|
sel.callback = _cb
|
||||||
|
view.add_item(sel)
|
||||||
|
await interaction.response.send_message("Sélectionnez le rôle staff :", view=view, ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigSetLogButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(label="Canal logs", style=discord.ButtonStyle.secondary, custom_id="ticket_cfg_log")
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
view = discord.ui.View(timeout=300)
|
||||||
|
sel = discord.ui.ChannelSelect(
|
||||||
|
placeholder="Canal de logs...",
|
||||||
|
custom_id="ticket_log_channel_select",
|
||||||
|
channel_types=[discord.ChannelType.text],
|
||||||
|
min_values=1,
|
||||||
|
max_values=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
config.log_channel_id = sel.values[0].id
|
||||||
|
storage.save_config(config)
|
||||||
|
await interaction.response.edit_message(content=f"Canal de logs : {sel.values[0].mention}", view=None)
|
||||||
|
|
||||||
|
sel.callback = _cb
|
||||||
|
view.add_item(sel)
|
||||||
|
await interaction.response.send_message("Sélectionnez le canal de logs :", view=view, ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketManagementView(discord.ui.LayoutView):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=None)
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay("### Gestion"))
|
||||||
|
container.add_item(discord.ui.ActionRow(ClaimButton(), TransferButton(), LockButton()))
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(discord.ui.ActionRow(AddMemberButton(), CloseButton()))
|
||||||
|
self.add_item(container)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSystem(commands.Cog):
|
||||||
|
ticket = app_commands.Group(name="ticket", description="Systeme de tickets")
|
||||||
|
|
||||||
|
def __init__(self, bot: commands.Bot):
|
||||||
|
self.bot = bot
|
||||||
|
self.bot.add_view(PanelView())
|
||||||
|
self.bot.add_view(TicketManagementView())
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_ready(self):
|
||||||
|
logger.info("TicketSystem cog charge")
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_message(self, message: discord.Message):
|
||||||
|
if message.author.bot:
|
||||||
|
return
|
||||||
|
if not message.guild:
|
||||||
|
return
|
||||||
|
|
||||||
|
ticket = storage.get_ticket(message.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
return
|
||||||
|
|
||||||
|
config = storage.get_config(message.guild.id)
|
||||||
|
member = message.guild.get_member(message.author.id)
|
||||||
|
if not member:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_staff = _is_staff(member, config)
|
||||||
|
|
||||||
|
from tickets.data import TicketMessage
|
||||||
|
|
||||||
|
msg = TicketMessage(
|
||||||
|
author_id=message.author.id,
|
||||||
|
author_name=str(message.author),
|
||||||
|
content=message.content,
|
||||||
|
timestamp=message.created_at.isoformat(),
|
||||||
|
attachments=[a.url for a in message.attachments],
|
||||||
|
is_staff=is_staff,
|
||||||
|
)
|
||||||
|
ticket.messages.append(msg)
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
@ticket.command(name="setup", description="Envoyer le panneau de tickets")
|
||||||
|
@app_commands.describe(channel="Canal pour le panneau")
|
||||||
|
@app_commands.checks.has_permissions(manage_guild=True)
|
||||||
|
async def ticket_setup(self, interaction: discord.Interaction, channel: discord.TextChannel):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
config.enabled = True
|
||||||
|
config.panel_channel_id = channel.id
|
||||||
|
storage.save_config(config)
|
||||||
|
view = PanelView()
|
||||||
|
msg = await channel.send(view=view)
|
||||||
|
config.panel_message_id = msg.id
|
||||||
|
storage.save_config(config)
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay(f"## Panneau envoyé\nCanal : {channel.mention}\nStatut : Actif"))
|
||||||
|
resp_view = discord.ui.LayoutView()
|
||||||
|
resp_view.add_item(container)
|
||||||
|
await interaction.response.send_message(view=resp_view, ephemeral=True)
|
||||||
|
|
||||||
|
@ticket.command(name="config", description="Configuration du système de tickets")
|
||||||
|
@app_commands.checks.has_permissions(manage_guild=True)
|
||||||
|
async def ticket_config(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
cats = "\n".join(f"- {c.emoji} **{c.name}**" for c in config.categories) or "*Aucune*"
|
||||||
|
staff_line = f"**Rôle staff :** <@&{config.staff_role_id}>\n" if config.staff_role_id else ""
|
||||||
|
log_line = f"**Canal de logs :** <#{config.log_channel_id}>\n" if config.log_channel_id else ""
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.TextDisplay(
|
||||||
|
f"## Configuration Tickets\n"
|
||||||
|
f"**Statut :** {'Actif' if config.enabled else 'Inactif'}\n"
|
||||||
|
f"{staff_line}{log_line}### Categories\n{cats}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.ActionRow(
|
||||||
|
ConfigToggleButton(config.enabled),
|
||||||
|
ConfigAddCatButton(),
|
||||||
|
ConfigSetStaffButton(),
|
||||||
|
ConfigSetLogButton(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
view = discord.ui.LayoutView()
|
||||||
|
view.add_item(container)
|
||||||
|
await interaction.response.send_message(view=view, ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot):
|
||||||
|
await bot.add_cog(TicketSystem(bot))
|
||||||
133
tickets/data.py
Normal file
133
tickets/data.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatus(str, Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPriority(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
NORMAL = "normal"
|
||||||
|
HIGH = "high"
|
||||||
|
URGENT = "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TicketCategory:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
emoji: str = ""
|
||||||
|
description: str = ""
|
||||||
|
staff_role_id: int | None = None
|
||||||
|
discord_category_id: int | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"emoji": self.emoji,
|
||||||
|
"description": self.description,
|
||||||
|
"staff_role_id": self.staff_role_id,
|
||||||
|
"discord_category_id": self.discord_category_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> TicketCategory:
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TicketMessage:
|
||||||
|
author_id: int
|
||||||
|
author_name: str
|
||||||
|
content: str
|
||||||
|
timestamp: str
|
||||||
|
attachments: list[str] = field(default_factory=list)
|
||||||
|
is_staff: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TicketData:
|
||||||
|
channel_id: int
|
||||||
|
guild_id: int
|
||||||
|
user_id: int
|
||||||
|
category_id: str
|
||||||
|
status: TicketStatus = TicketStatus.OPEN
|
||||||
|
created_at: str = ""
|
||||||
|
closed_at: str | None = None
|
||||||
|
closed_by: int | None = None
|
||||||
|
claimed_by: int | None = None
|
||||||
|
locked: bool = False
|
||||||
|
priority: TicketPriority = TicketPriority.NORMAL
|
||||||
|
messages: list[TicketMessage] = field(default_factory=list)
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"channel_id": self.channel_id,
|
||||||
|
"guild_id": self.guild_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"category_id": self.category_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"closed_at": self.closed_at,
|
||||||
|
"closed_by": self.closed_by,
|
||||||
|
"claimed_by": self.claimed_by,
|
||||||
|
"locked": self.locked,
|
||||||
|
"priority": self.priority.value,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"author_id": m.author_id,
|
||||||
|
"author_name": m.author_name,
|
||||||
|
"content": m.content,
|
||||||
|
"timestamp": m.timestamp,
|
||||||
|
"attachments": m.attachments,
|
||||||
|
"is_staff": m.is_staff,
|
||||||
|
}
|
||||||
|
for m in self.messages
|
||||||
|
],
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> TicketData:
|
||||||
|
messages = [TicketMessage(**m) for m in data.pop("messages", [])]
|
||||||
|
data["status"] = TicketStatus(data.get("status", "open"))
|
||||||
|
data["priority"] = TicketPriority(data.get("priority", "normal"))
|
||||||
|
data.pop("transcript_path", None)
|
||||||
|
data.pop("rating", None)
|
||||||
|
data.pop("feedback", None)
|
||||||
|
return cls(messages=messages, **{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuildTicketConfig:
|
||||||
|
guild_id: int
|
||||||
|
enabled: bool = False
|
||||||
|
categories: list[TicketCategory] = field(default_factory=list)
|
||||||
|
staff_role_id: int | None = None
|
||||||
|
log_channel_id: int | None = None
|
||||||
|
panel_channel_id: int | None = None
|
||||||
|
panel_message_id: int | None = None
|
||||||
|
transcript_enabled: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"guild_id": self.guild_id,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"categories": [c.to_dict() for c in self.categories],
|
||||||
|
"staff_role_id": self.staff_role_id,
|
||||||
|
"log_channel_id": self.log_channel_id,
|
||||||
|
"panel_channel_id": self.panel_channel_id,
|
||||||
|
"panel_message_id": self.panel_message_id,
|
||||||
|
"transcript_enabled": self.transcript_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> GuildTicketConfig:
|
||||||
|
categories = [TicketCategory.from_dict(c) for c in data.pop("categories", [])]
|
||||||
|
return cls(categories=categories, **{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
86
tickets/storage.py
Normal file
86
tickets/storage.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from config import DATA_DIR
|
||||||
|
from tickets.data import GuildTicketConfig, TicketData
|
||||||
|
|
||||||
|
logger = logging.getLogger("vision")
|
||||||
|
|
||||||
|
TICKETS_DIR = DATA_DIR / "tickets"
|
||||||
|
_CONFIG_FILE = TICKETS_DIR / "config.json"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_config_cache: dict[int, GuildTicketConfig] = {}
|
||||||
|
_tickets_cache: dict[int, TicketData] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs():
|
||||||
|
TICKETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all_configs() -> dict[int, GuildTicketConfig]:
|
||||||
|
global _config_cache
|
||||||
|
_ensure_dirs()
|
||||||
|
if _config_cache:
|
||||||
|
return _config_cache
|
||||||
|
if _CONFIG_FILE.exists():
|
||||||
|
with open(_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
_config_cache = {int(k): GuildTicketConfig.from_dict(v) for k, v in raw.items()}
|
||||||
|
else:
|
||||||
|
_config_cache = {}
|
||||||
|
return _config_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all_configs():
|
||||||
|
_ensure_dirs()
|
||||||
|
data = {str(k): v.to_dict() for k, v in _config_cache.items()}
|
||||||
|
with open(_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(guild_id: int) -> GuildTicketConfig:
|
||||||
|
with _lock:
|
||||||
|
configs = _load_all_configs()
|
||||||
|
if guild_id not in configs:
|
||||||
|
configs[guild_id] = GuildTicketConfig(guild_id=guild_id)
|
||||||
|
return configs[guild_id]
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: GuildTicketConfig):
|
||||||
|
with _lock:
|
||||||
|
_config_cache[config.guild_id] = config
|
||||||
|
_save_all_configs()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket(channel_id: int) -> TicketData | None:
|
||||||
|
with _lock:
|
||||||
|
if channel_id in _tickets_cache:
|
||||||
|
return _tickets_cache[channel_id]
|
||||||
|
ticket_file = TICKETS_DIR / f"{channel_id}.json"
|
||||||
|
if ticket_file.exists():
|
||||||
|
with open(ticket_file, "r", encoding="utf-8") as f:
|
||||||
|
ticket = TicketData.from_dict(json.load(f))
|
||||||
|
_tickets_cache[channel_id] = ticket
|
||||||
|
return ticket
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_ticket(ticket: TicketData):
|
||||||
|
with _lock:
|
||||||
|
_tickets_cache[ticket.channel_id] = ticket
|
||||||
|
_ensure_dirs()
|
||||||
|
ticket_file = TICKETS_DIR / f"{ticket.channel_id}.json"
|
||||||
|
with open(ticket_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(ticket.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_ticket(channel_id: int):
|
||||||
|
with _lock:
|
||||||
|
_tickets_cache.pop(channel_id, None)
|
||||||
|
ticket_file = TICKETS_DIR / f"{channel_id}.json"
|
||||||
|
if ticket_file.exists():
|
||||||
|
ticket_file.unlink()
|
||||||
174
tickets/transcript.py
Normal file
174
tickets/transcript.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from tickets.data import TicketData
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ts(dt_str: str) -> str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
return dt.strftime("%d/%m/%Y %H:%M:%S")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return dt_str
|
||||||
|
|
||||||
|
|
||||||
|
def generate_transcript(ticket: TicketData, guild_name: str = "") -> str:
|
||||||
|
now = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S")
|
||||||
|
|
||||||
|
messages_html = ""
|
||||||
|
for msg in ticket.messages:
|
||||||
|
content = html.escape(msg.content) if msg.content else ""
|
||||||
|
ts = _format_ts(msg.timestamp)
|
||||||
|
badge = '<span class="badge badge-staff">STAFF</span>' if msg.is_staff else ""
|
||||||
|
|
||||||
|
att_html = ""
|
||||||
|
for att in msg.attachments:
|
||||||
|
if any(att.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp")):
|
||||||
|
att_html += f'<div class="attachment"><img src="{html.escape(att)}" /></div>'
|
||||||
|
else:
|
||||||
|
att_html += f'<div class="attachment"><a href="{html.escape(att)}">{html.escape(att.rsplit("/", 1)[-1])}</a></div>'
|
||||||
|
|
||||||
|
messages_html += f"""
|
||||||
|
<div class="message {"staff" if msg.is_staff else "user"}">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="author">{html.escape(msg.author_name)}</span>
|
||||||
|
{badge}
|
||||||
|
<span class="timestamp">{ts}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{content}</div>
|
||||||
|
{att_html}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not messages_html:
|
||||||
|
messages_html = '<div class="empty">Aucun message.</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.channel_id}</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: #1a1d23;
|
||||||
|
border: 1px solid #2f3136;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.header h1 {{
|
||||||
|
font-size: 22px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}}
|
||||||
|
.header .meta {{
|
||||||
|
font-size: 13px;
|
||||||
|
color: #72767d;
|
||||||
|
}}
|
||||||
|
.header .meta span {{
|
||||||
|
background: #2f3136;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}}
|
||||||
|
.messages {{
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}}
|
||||||
|
.message {{
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.message:hover {{
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}}
|
||||||
|
.message.staff {{
|
||||||
|
border-left: 3px solid #57f287;
|
||||||
|
}}
|
||||||
|
.message.user {{
|
||||||
|
border-left: 3px solid #5865f2;
|
||||||
|
}}
|
||||||
|
.msg-header {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}}
|
||||||
|
.author {{
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
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: 11px;
|
||||||
|
color: #4f545c;
|
||||||
|
margin-left: auto;
|
||||||
|
}}
|
||||||
|
.msg-content {{
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #dcddde;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-left: 0;
|
||||||
|
}}
|
||||||
|
.attachment {{
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
color: #4f545c;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #2f3136;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Ticket #{ticket.channel_id}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Categorie: {html.escape(ticket.category_id)}</span>
|
||||||
|
<span>User: {ticket.user_id}</span>
|
||||||
|
<span>Cree: {_format_ts(ticket.created_at)}</span>
|
||||||
|
{f"<span>Ferme: {_format_ts(ticket.closed_at)}</span>" if ticket.closed_at else ""}
|
||||||
|
<span>Genere: {now}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="messages">{messages_html}</div>
|
||||||
|
<div class="footer">Vision Studio — Transcript</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
619
tickets/ui.py
Normal file
619
tickets/ui.py
Normal file
|
|
@ -0,0 +1,619 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from tickets import storage
|
||||||
|
from tickets.data import TicketCategory, TicketData, TicketStatus
|
||||||
|
from tickets.transcript import generate_transcript
|
||||||
|
|
||||||
|
logger = logging.getLogger("vision")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_staff(member: discord.Member, config) -> bool:
|
||||||
|
if member.guild_permissions.administrator:
|
||||||
|
return True
|
||||||
|
if config.staff_role_id and config.staff_role_id in [r.id for r in member.roles]:
|
||||||
|
return True
|
||||||
|
return any(cat.staff_role_id and cat.staff_role_id in [r.id for r in member.roles] for cat in config.categories)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_log(guild: discord.Guild, config, embed: discord.Embed):
|
||||||
|
if config.log_channel_id:
|
||||||
|
ch = guild.get_channel(config.log_channel_id)
|
||||||
|
if ch:
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
await ch.send(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
class PanelCreateButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Créer un ticket",
|
||||||
|
style=discord.ButtonStyle.primary,
|
||||||
|
custom_id="ticket_panel_create",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
if not config.enabled:
|
||||||
|
await interaction.response.send_message("Le système de tickets est désactivé.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
categories = config.categories
|
||||||
|
if not categories:
|
||||||
|
await interaction.response.send_message("Aucune catégorie configurée.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(categories) == 1:
|
||||||
|
cat = categories[0]
|
||||||
|
await interaction.response.send_modal(TicketModal(cat.id, cat.name))
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Choisissez une catégorie :",
|
||||||
|
view=CategorySelectView(categories),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySelect(discord.ui.Select):
|
||||||
|
def __init__(self, categories: list[TicketCategory]):
|
||||||
|
options = [
|
||||||
|
discord.SelectOption(
|
||||||
|
label=c.name,
|
||||||
|
value=c.id,
|
||||||
|
emoji=c.emoji or None,
|
||||||
|
description=c.description[:100] if c.description else None,
|
||||||
|
)
|
||||||
|
for c in categories[:25]
|
||||||
|
]
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Sélectionnez une catégorie...",
|
||||||
|
options=options,
|
||||||
|
custom_id="ticket_category_select",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
cat_id = self.values[0]
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
cat = next((c for c in config.categories if c.id == cat_id), None)
|
||||||
|
if not cat:
|
||||||
|
await interaction.response.send_message("Catégorie introuvable.", ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_modal(TicketModal(cat.id, cat.name))
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySelectView(discord.ui.View):
|
||||||
|
def __init__(self, categories: list[TicketCategory]):
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(CategorySelect(categories))
|
||||||
|
|
||||||
|
|
||||||
|
class TicketModal(discord.ui.Modal, title="Nouveau Ticket"):
|
||||||
|
reason = discord.ui.TextInput(
|
||||||
|
label="Décris ton problème",
|
||||||
|
style=discord.TextStyle.paragraph,
|
||||||
|
placeholder="Explique brièvement ta demande...",
|
||||||
|
required=True,
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, category_id: str, category_name: str):
|
||||||
|
super().__init__()
|
||||||
|
self.category_id = category_id
|
||||||
|
self.category_name = category_name
|
||||||
|
|
||||||
|
async def on_submit(self, interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
guild = interaction.guild
|
||||||
|
|
||||||
|
cat = next((c for c in config.categories if c.id == self.category_id), None)
|
||||||
|
if not cat:
|
||||||
|
await interaction.followup.send("Catégorie introuvable.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
interaction.user: discord.PermissionOverwrite(
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if config.staff_role_id:
|
||||||
|
staff_role = guild.get_role(config.staff_role_id)
|
||||||
|
if staff_role:
|
||||||
|
overwrites[staff_role] = discord.PermissionOverwrite(
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
attach_files=True,
|
||||||
|
)
|
||||||
|
if cat.staff_role_id:
|
||||||
|
cat_role = guild.get_role(cat.staff_role_id)
|
||||||
|
if cat_role:
|
||||||
|
overwrites[cat_role] = discord.PermissionOverwrite(
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
attach_files=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
discord_cat = guild.get_channel(cat.discord_category_id) if cat.discord_category_id else None
|
||||||
|
safe_name = interaction.user.name.lower().replace(" ", "-")[:20]
|
||||||
|
channel = await guild.create_text_channel(
|
||||||
|
f"ticket-{safe_name}",
|
||||||
|
category=discord_cat,
|
||||||
|
overwrites=overwrites,
|
||||||
|
topic=f"Ticket de {interaction.user} | {cat.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = TicketData(
|
||||||
|
channel_id=channel.id,
|
||||||
|
guild_id=guild.id,
|
||||||
|
user_id=interaction.user.id,
|
||||||
|
category_id=cat.id,
|
||||||
|
created_at=_now_iso(),
|
||||||
|
reason=self.reason.value,
|
||||||
|
)
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
mention = interaction.user.mention
|
||||||
|
if config.staff_role_id:
|
||||||
|
mention += f" <@&{config.staff_role_id}>"
|
||||||
|
if cat.staff_role_id:
|
||||||
|
mention += f" <@&{cat.staff_role_id}>"
|
||||||
|
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay(mention))
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.TextDisplay(
|
||||||
|
f"## Ticket — {cat.name}\n"
|
||||||
|
f"**Utilisateur :** {interaction.user.mention}\n"
|
||||||
|
f"**Catégorie :** {cat.emoji} {cat.name}\n"
|
||||||
|
f"**Raison :**\n{self.reason.value}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(discord.ui.TextDisplay("### Gestion"))
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.ActionRow(
|
||||||
|
ClaimButton(),
|
||||||
|
TransferButton(),
|
||||||
|
LockButton(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
container.add_item(discord.ui.Separator())
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.ActionRow(
|
||||||
|
AddMemberButton(),
|
||||||
|
CloseButton(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
view = discord.ui.LayoutView()
|
||||||
|
view.add_item(container)
|
||||||
|
|
||||||
|
await channel.send(view=view)
|
||||||
|
|
||||||
|
await interaction.followup.send(f"Ticket créé : {channel.mention}", ephemeral=True)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ticket créé",
|
||||||
|
description=f"**Catégorie :** {cat.name}\n**Utilisateur :** {interaction.user.mention}\n**Canal :** {channel.mention}",
|
||||||
|
color=discord.Color.green(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(guild, config, embed)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Réclamer",
|
||||||
|
style=discord.ButtonStyle.success,
|
||||||
|
custom_id="ticket_claim",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut réclamer un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if ticket.claimed_by:
|
||||||
|
if ticket.claimed_by == interaction.user.id:
|
||||||
|
ticket.claimed_by = None
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"{interaction.user.mention} a retiré son réclamation.", ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"Ce ticket est déjà réclamé par <@{ticket.claimed_by}>.", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ticket.claimed_by = interaction.user.id
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ticket claim",
|
||||||
|
description=f"Réclamé par {interaction.user.mention}",
|
||||||
|
color=discord.Color.green(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, config, embed)
|
||||||
|
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay(f"## Ticket réclamé\nRéclamé par {interaction.user.mention}"))
|
||||||
|
view = discord.ui.LayoutView()
|
||||||
|
view.add_item(container)
|
||||||
|
await interaction.response.send_message(view=view)
|
||||||
|
|
||||||
|
|
||||||
|
class AddMemberButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Ajouter un membre",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="ticket_add_member",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut ajouter des membres.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Sélectionnez le membre à ajouter :",
|
||||||
|
view=AddMemberSelectView(),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddMemberSelect(discord.ui.UserSelect):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Sélectionnez un membre...",
|
||||||
|
custom_id="ticket_add_member_select",
|
||||||
|
min_values=1,
|
||||||
|
max_values=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
user = self.values[0]
|
||||||
|
await interaction.channel.set_permissions(
|
||||||
|
user,
|
||||||
|
view_channel=True,
|
||||||
|
send_messages=True,
|
||||||
|
read_message_history=True,
|
||||||
|
)
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content=f"{user.mention} a été ajouté au ticket.",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Membre ajouté",
|
||||||
|
description=f"{user.mention} a été ajouté par {interaction.user.mention}",
|
||||||
|
color=discord.Color.blue(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
|
||||||
|
|
||||||
|
|
||||||
|
class AddMemberSelectView(discord.ui.View):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(AddMemberSelect())
|
||||||
|
|
||||||
|
|
||||||
|
class TransferButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Transférer",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="ticket_transfer",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut transférer.", ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Sélectionnez le staff :",
|
||||||
|
view=TransferSelectView(),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferSelect(discord.ui.UserSelect):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Sélectionnez un staff...",
|
||||||
|
custom_id="ticket_transfer_select",
|
||||||
|
min_values=1,
|
||||||
|
max_values=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
user = self.values[0]
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if ticket:
|
||||||
|
ticket.claimed_by = user.id
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content=f"Ticket transféré à {user.mention}.",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ticket transféré",
|
||||||
|
description=f"Transféré à {user.mention} par {interaction.user.mention}",
|
||||||
|
color=discord.Color.blue(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferSelectView(discord.ui.View):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(TransferSelect())
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Priorite",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="ticket_priority",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut changer la priorité.", ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Sélectionnez la priorité :",
|
||||||
|
view=PrioritySelectView(),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrioritySelect(discord.ui.Select):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
placeholder="Sélectionnez la priorité...",
|
||||||
|
custom_id="ticket_priority_select",
|
||||||
|
options=[
|
||||||
|
discord.SelectOption(label="Basse", value="low", emoji="🟢"),
|
||||||
|
discord.SelectOption(label="Normale", value="normal", emoji="🔵"),
|
||||||
|
discord.SelectOption(label="Haute", value="high", emoji="🟠"),
|
||||||
|
discord.SelectOption(label="Urgente", value="urgent", emoji="🔴"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
from tickets.data import TicketPriority
|
||||||
|
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if ticket:
|
||||||
|
ticket.priority = TicketPriority(self.values[0])
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content=f"Priorite definie sur **{self.values[0]}**.",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Priorite changee",
|
||||||
|
description=f"Priorite : **{self.values[0]}** par {interaction.user.mention}",
|
||||||
|
color=discord.Color.blue(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, storage.get_config(interaction.guild_id), embed)
|
||||||
|
|
||||||
|
|
||||||
|
class PrioritySelectView(discord.ui.View):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(timeout=300)
|
||||||
|
self.add_item(PrioritySelect())
|
||||||
|
|
||||||
|
|
||||||
|
class LockButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Verrouiller",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="ticket_lock",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut verrouiller/déverrouiller.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
ticket.locked = not ticket.locked
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
overwrites = interaction.channel.overwrites_for(interaction.guild.default_role)
|
||||||
|
overwrites.send_messages = not ticket.locked
|
||||||
|
await interaction.channel.set_permissions(interaction.guild.default_role, overwrite=overwrites)
|
||||||
|
|
||||||
|
self.label = "Déverrouiller" if ticket.locked else "Verrouiller"
|
||||||
|
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'} par {interaction.user.mention}."
|
||||||
|
)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'}",
|
||||||
|
description=f"Par {interaction.user.mention}",
|
||||||
|
color=discord.Color.orange() if ticket.locked else discord.Color.green(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, config, embed)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Infos",
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
custom_id="ticket_stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
cat = next((c for c in config.categories if c.id == ticket.category_id), None)
|
||||||
|
|
||||||
|
user = interaction.guild.get_member(ticket.user_id)
|
||||||
|
claimed = interaction.guild.get_member(ticket.claimed_by) if ticket.claimed_by else None
|
||||||
|
msg_count = len(ticket.messages)
|
||||||
|
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(
|
||||||
|
discord.ui.TextDisplay(
|
||||||
|
f"## Infos Ticket\n"
|
||||||
|
f"**Categorie :** {cat.emoji} {cat.name if cat else ticket.category_id}\n"
|
||||||
|
f"**Utilisateur :** {user.mention if user else ticket.user_id}\n"
|
||||||
|
f"**Claim par :** {claimed.mention if claimed else 'Non claim'}\n"
|
||||||
|
f"**Priorite :** {ticket.priority.value}\n"
|
||||||
|
f"**Statut :** {'Lock' if ticket.locked else 'Ouvert'}\n"
|
||||||
|
f"**Messages :** {msg_count}\n"
|
||||||
|
f"**Cree :** <t:{int(os.path.fromisoformat(ticket.created_at).timestamp())}:R>"
|
||||||
|
if ticket.created_at
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
view = discord.ui.LayoutView()
|
||||||
|
view.add_item(container)
|
||||||
|
await interaction.response.send_message(view=view, ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CloseButton(discord.ui.Button):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
label="Fermer le ticket",
|
||||||
|
style=discord.ButtonStyle.danger,
|
||||||
|
custom_id="ticket_close",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def callback(self, interaction: discord.Interaction):
|
||||||
|
config = storage.get_config(interaction.guild_id)
|
||||||
|
ticket = storage.get_ticket(interaction.channel.id)
|
||||||
|
if not ticket:
|
||||||
|
await interaction.response.send_message("Ce n'est pas un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
member = interaction.guild.get_member(interaction.user.id)
|
||||||
|
if not member or not _is_staff(member, config):
|
||||||
|
await interaction.response.send_message("Seul le staff peut fermer un ticket.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
ticket.status = TicketStatus.CLOSED
|
||||||
|
ticket.closed_at = _now_iso()
|
||||||
|
ticket.closed_by = interaction.user.id
|
||||||
|
|
||||||
|
if config.transcript_enabled:
|
||||||
|
html_content = generate_transcript(ticket, interaction.guild.name)
|
||||||
|
transcript_file = discord.File(
|
||||||
|
fp=__import__("io").BytesIO(html_content.encode()),
|
||||||
|
filename=f"ticket_{ticket.channel_id}.html",
|
||||||
|
)
|
||||||
|
if config.log_channel_id:
|
||||||
|
ch = interaction.guild.get_channel(config.log_channel_id)
|
||||||
|
if ch:
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
await ch.send(
|
||||||
|
f"Ticket fermé par {interaction.user.mention}",
|
||||||
|
file=transcript_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
for msg in ticket.messages:
|
||||||
|
if not msg.is_staff:
|
||||||
|
user = interaction.guild.get_member(msg.author_id)
|
||||||
|
if user:
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
await user.send(
|
||||||
|
"Ton ticket a été fermé.",
|
||||||
|
file=discord.File(
|
||||||
|
fp=__import__("io").BytesIO(html_content.encode()),
|
||||||
|
filename=f"ticket_{ticket.channel_id}.html",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
storage.save_ticket(ticket)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Ticket fermé",
|
||||||
|
description=f"**Fermé par :** {interaction.user.mention}\n**Catégorie :** {ticket.category_id}",
|
||||||
|
color=discord.Color.red(),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await _send_log(interaction.guild, config, embed)
|
||||||
|
|
||||||
|
container = discord.ui.Container()
|
||||||
|
container.add_item(discord.ui.TextDisplay("## Ticket fermé\nCe ticket sera supprimé dans 5 secondes."))
|
||||||
|
view = discord.ui.LayoutView()
|
||||||
|
view.add_item(container)
|
||||||
|
await interaction.followup.send(view=view)
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
with contextlib.suppress(discord.HTTPException):
|
||||||
|
await interaction.channel.delete(reason=f"Ticket fermé par {interaction.user}")
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
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_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
|
|
||||||
_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)
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
import html
|
|
||||||
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)
|
|
||||||
|
|
@ -26,7 +26,8 @@ async def main():
|
||||||
instance = "DEV" if config.DEV else "PROD"
|
instance = "DEV" if config.DEV else "PROD"
|
||||||
logger.info(f"Lancement de Vision Studio [{instance}]...")
|
logger.info(f"Lancement de Vision Studio [{instance}]...")
|
||||||
|
|
||||||
await start_health_server(config.HEALTH_PORT)
|
if not config.DEV:
|
||||||
|
await start_health_server(config.HEALTH_PORT)
|
||||||
|
|
||||||
bot = VisionBot()
|
bot = VisionBot()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue