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
0
tickets/__init__.py
Normal file
0
tickets/__init__.py
Normal file
241
tickets/cog.py
Normal file
241
tickets/cog.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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 PanelCreateButton
|
||||
|
||||
logger = logging.getLogger("vision")
|
||||
|
||||
|
||||
class TicketSystem(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.bot.add_view(PanelView())
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_ready(self):
|
||||
logger.info("TicketSystem cog charge")
|
||||
|
||||
|
||||
class PanelView(discord.ui.LayoutView):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=None)
|
||||
container = discord.ui.Container()
|
||||
container.add_item(
|
||||
discord.ui.TextDisplay(
|
||||
"## Systeme de tickets\n"
|
||||
"Besoin d'aide ? Creez un ticket et notre equipe vous repondra.\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)
|
||||
|
||||
|
||||
ticket_group = app_commands.Group(name="ticket", description="Systeme de tickets")
|
||||
|
||||
|
||||
@ticket_group.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(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 envoye\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_group.command(name="config", description="Configuration du systeme de tickets")
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def ticket_config(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*"
|
||||
|
||||
container = discord.ui.Container()
|
||||
container.add_item(
|
||||
discord.ui.TextDisplay(
|
||||
f"## Configuration Tickets\n"
|
||||
f"**Statut :** {'🟢 Actif' if config.enabled else '🔴 Inactif'}\n"
|
||||
f"**Role staff :** <@&{config.staff_role_id}>\n"
|
||||
if config.staff_role_id
|
||||
else f"**Canal de logs :** <#{config.log_channel_id}>\n"
|
||||
if config.log_channel_id
|
||||
else f"### 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)
|
||||
|
||||
|
||||
class ConfigToggleButton(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="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 categorie",
|
||||
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):
|
||||
await interaction.response.send_message(
|
||||
"Selectionnez le role staff :",
|
||||
view=StaffRoleSelectView(),
|
||||
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):
|
||||
await interaction.response.send_message(
|
||||
"Selectionnez le canal de logs :",
|
||||
view=LogChannelSelectView(),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
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 enregistrees."))
|
||||
self.add_item(container)
|
||||
|
||||
|
||||
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=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"Categorie **{cat.name}** ajoutee.", ephemeral=True)
|
||||
|
||||
|
||||
class StaffRoleSelect(discord.ui.RoleSelect):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
placeholder="Role staff...",
|
||||
custom_id="ticket_staff_role_select",
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
config = storage.get_config(interaction.guild_id)
|
||||
config.staff_role_id = self.values[0].id
|
||||
storage.save_config(config)
|
||||
await interaction.response.edit_message(
|
||||
content=f"Role staff defini : {self.values[0].mention}",
|
||||
view=None,
|
||||
)
|
||||
|
||||
|
||||
class StaffRoleSelectView(discord.ui.View):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=300)
|
||||
self.add_item(StaffRoleSelect())
|
||||
|
||||
|
||||
class LogChannelSelect(discord.ui.ChannelSelect):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
placeholder="Canal de logs...",
|
||||
custom_id="ticket_log_channel_select",
|
||||
channel_types=[discord.ChannelType.text, discord.ChannelType.public_thread],
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
config = storage.get_config(interaction.guild_id)
|
||||
config.log_channel_id = self.values[0].id
|
||||
storage.save_config(config)
|
||||
await interaction.response.edit_message(
|
||||
content=f"Canal de logs defini : {self.values[0].mention}",
|
||||
view=None,
|
||||
)
|
||||
|
||||
|
||||
class LogChannelSelectView(discord.ui.View):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=300)
|
||||
self.add_item(LogChannelSelect())
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(TicketSystem(bot))
|
||||
119
tickets/data.py
Normal file
119
tickets/data.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TicketStatus(str, Enum):
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
@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
|
||||
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,
|
||||
"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.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.get("attachments", []) if hasattr(msg, "attachments") else 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>"""
|
||||
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