VisionApp/tickets/ui.py
Mathis 4d1e265848
All checks were successful
ci / ci (push) Successful in 47s
feat(ticket): add claim, add member, persistent management view
- ClaimButton: staff claim/unclaim with log
- AddMemberButton: UserSelect to add member to ticket channel
- TicketManagementView: persistent LayoutView with all 3 buttons
- claimed_by field in TicketData
- Buttons registered as persistent views for bot restart survival
2026-09-25 19:28:15 +02:00

406 lines
14 KiB
Python

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(
ClaimButton(),
AddMemberButton(),
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 ClaimButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Claim",
style=discord.ButtonStyle.success,
custom_id="ticket_claim",
emoji="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 claim 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 retire son claim.", ephemeral=True
)
else:
await interaction.response.send_message(
f"Ce ticket est deja claim 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"Claim 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 claim\nClaim 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",
emoji="Ajouter",
)
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(
"Selectionnez le membre a ajouter :",
view=AddMemberSelectView(),
ephemeral=True,
)
class AddMemberSelect(discord.ui.UserSelect):
def __init__(self):
super().__init__(
placeholder="Selectionnez 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 ete ajoute au ticket.",
view=None,
)
embed = discord.Embed(
title="Membre ajoute",
description=f"{user.mention} a ete ajoute 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 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}")