feat(ticket): rewrite ticket system from scratch inspired by Kuby
All checks were successful
ci / ci (push) Successful in 46s
All checks were successful
ci / ci (push) Successful in 46s
New minimal ticket system (dev only): - Panel with create ticket button (Components V2, no accent colors) - Category selection via dropdown or direct modal (1 cat) - Ticket channel creation with permissions, staff role, welcome message - Close flow: staff check, transcript HTML (dark theme), log, auto-delete - /ticket setup, /ticket config (interactive panel) - Storage: JSON per-ticket + config.json (singleton pattern) - Modular architecture: data.py, storage.py, ui.py, transcript.py, cog.py - Removed old modmail system
This commit is contained in:
parent
4d2be145aa
commit
57b6f09dd7
10 changed files with 905 additions and 943 deletions
284
tickets/ui.py
Normal file
284
tickets/ui.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
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="Creer 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 systeme de tickets est desactive.", ephemeral=True)
|
||||
return
|
||||
|
||||
categories = config.categories
|
||||
if not categories:
|
||||
await interaction.response.send_message("Aucune categorie configuree.", 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 categorie :",
|
||||
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="Selectionnez une categorie...",
|
||||
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("Categorie 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="Decris ton probleme",
|
||||
style=discord.TextStyle.paragraph,
|
||||
placeholder="Explique brievement 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("Categorie 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)
|
||||
|
||||
container = discord.ui.Container()
|
||||
container.add_item(
|
||||
discord.ui.TextDisplay(
|
||||
f"## Ticket — {cat.name}\n"
|
||||
f"**Utilisateur :** {interaction.user.mention}\n"
|
||||
f"**Categorie :** {cat.emoji} {cat.name}\n"
|
||||
f"**Raison :**\n{self.reason.value}"
|
||||
)
|
||||
)
|
||||
container.add_item(discord.ui.Separator())
|
||||
container.add_item(discord.ui.ActionRow(CloseButton()))
|
||||
view = discord.ui.LayoutView()
|
||||
view.add_item(container)
|
||||
|
||||
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}>"
|
||||
|
||||
await channel.send(content=mention, view=view)
|
||||
|
||||
await interaction.followup.send(f"Ticket cree : {channel.mention}", ephemeral=True)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Ticket cree",
|
||||
description=f"**Categorie :** {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 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 = generate_transcript(ticket, interaction.guild.name)
|
||||
transcript_file = discord.File(
|
||||
fp=__import__("io").BytesIO(html.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 ferme 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 ete ferme.",
|
||||
file=discord.File(
|
||||
fp=__import__("io").BytesIO(html.encode()),
|
||||
filename=f"ticket_{ticket.channel_id}.html",
|
||||
),
|
||||
)
|
||||
break
|
||||
|
||||
storage.save_ticket(ticket)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Ticket ferme",
|
||||
description=f"**Ferme par :** {interaction.user.mention}\n**Categorie :** {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 ferme\nCe ticket sera supprime 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 ferme par {interaction.user}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue