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
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue