feat(ticket): add transfer, priority, lock/unlock, stats, message recording
All checks were successful
ci / ci (push) Successful in 44s

- TransferButton: transfer ticket to another staff member
- PriorityButton: set ticket priority (low/normal/high/urgent)
- LockButton: lock/unlock ticket (toggle channel permissions)
- StatsButton: show ticket info (category, user, claimed, priority, messages)
- on_message listener: records all messages for transcript
- TicketData: added locked, priority fields
This commit is contained in:
Mathis 2026-09-25 19:38:24 +02:00
parent 4d1e265848
commit e46c04fe98
3 changed files with 269 additions and 2 deletions

View file

@ -8,7 +8,17 @@ from discord.ext import commands
from tickets import storage from tickets import storage
from tickets.data import TicketCategory from tickets.data import TicketCategory
from tickets.ui import AddMemberButton, ClaimButton, CloseButton, PanelCreateButton from tickets.ui import (
AddMemberButton,
ClaimButton,
CloseButton,
LockButton,
PanelCreateButton,
PriorityButton,
StatsButton,
TransferButton,
_is_staff,
)
logger = logging.getLogger("vision") logger = logging.getLogger("vision")
@ -134,7 +144,8 @@ class TicketManagementView(discord.ui.LayoutView):
def __init__(self): def __init__(self):
super().__init__(timeout=None) super().__init__(timeout=None)
container = discord.ui.Container() container = discord.ui.Container()
container.add_item(discord.ui.ActionRow(ClaimButton(), AddMemberButton(), CloseButton())) container.add_item(discord.ui.ActionRow(ClaimButton(), AddMemberButton(), TransferButton()))
container.add_item(discord.ui.ActionRow(PriorityButton(), LockButton(), StatsButton(), CloseButton()))
self.add_item(container) self.add_item(container)
@ -150,6 +161,37 @@ class TicketSystem(commands.Cog):
async def on_ready(self): async def on_ready(self):
logger.info("TicketSystem cog charge") 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") @ticket.command(name="setup", description="Envoyer le panneau de tickets")
@app_commands.describe(channel="Canal pour le panneau") @app_commands.describe(channel="Canal pour le panneau")
@app_commands.checks.has_permissions(manage_guild=True) @app_commands.checks.has_permissions(manage_guild=True)

View file

@ -9,6 +9,13 @@ class TicketStatus(str, Enum):
CLOSED = "closed" CLOSED = "closed"
class TicketPriority(str, Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
@dataclass @dataclass
class TicketCategory: class TicketCategory:
id: str id: str
@ -54,6 +61,8 @@ class TicketData:
closed_at: str | None = None closed_at: str | None = None
closed_by: int | None = None closed_by: int | None = None
claimed_by: int | None = None claimed_by: int | None = None
locked: bool = False
priority: TicketPriority = TicketPriority.NORMAL
messages: list[TicketMessage] = field(default_factory=list) messages: list[TicketMessage] = field(default_factory=list)
reason: str = "" reason: str = ""
@ -68,6 +77,8 @@ class TicketData:
"closed_at": self.closed_at, "closed_at": self.closed_at,
"closed_by": self.closed_by, "closed_by": self.closed_by,
"claimed_by": self.claimed_by, "claimed_by": self.claimed_by,
"locked": self.locked,
"priority": self.priority.value,
"messages": [ "messages": [
{ {
"author_id": m.author_id, "author_id": m.author_id,
@ -86,6 +97,7 @@ class TicketData:
def from_dict(cls, data: dict) -> TicketData: def from_dict(cls, data: dict) -> TicketData:
messages = [TicketMessage(**m) for m in data.pop("messages", [])] messages = [TicketMessage(**m) for m in data.pop("messages", [])]
data["status"] = TicketStatus(data.get("status", "open")) data["status"] = TicketStatus(data.get("status", "open"))
data["priority"] = TicketPriority(data.get("priority", "normal"))
data.pop("transcript_path", None) data.pop("transcript_path", None)
data.pop("rating", None) data.pop("rating", None)
data.pop("feedback", None) data.pop("feedback", None)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import logging import logging
import os
from datetime import datetime, timezone from datetime import datetime, timezone
import discord import discord
@ -189,6 +190,14 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"):
discord.ui.ActionRow( discord.ui.ActionRow(
ClaimButton(), ClaimButton(),
AddMemberButton(), AddMemberButton(),
TransferButton(),
)
)
container.add_item(
discord.ui.ActionRow(
PriorityButton(),
LockButton(),
StatsButton(),
CloseButton(), CloseButton(),
) )
) )
@ -330,6 +339,210 @@ class AddMemberSelectView(discord.ui.View):
self.add_item(AddMemberSelect()) self.add_item(AddMemberSelect())
class TransferButton(discord.ui.Button):
def __init__(self):
super().__init__(
label="Transfer",
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 transferer.", ephemeral=True)
return
await interaction.response.send_message(
"Selectionnez le staff :",
view=TransferSelectView(),
ephemeral=True,
)
class TransferSelect(discord.ui.UserSelect):
def __init__(self):
super().__init__(
placeholder="Selectionnez 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 transfere a {user.mention}.",
view=None,
)
embed = discord.Embed(
title="Ticket transfere",
description=f"Transfere a {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 priorite.", ephemeral=True)
return
await interaction.response.send_message(
"Selectionnez la priorite :",
view=PrioritySelectView(),
ephemeral=True,
)
class PrioritySelect(discord.ui.Select):
def __init__(self):
super().__init__(
placeholder="Selectionnez la priorite...",
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="Lock",
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 lock/unlock.", 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 = "Unlock" if ticket.locked else "Lock"
await interaction.response.send_message(
f"Ticket {'locked' if ticket.locked else 'unlocked'} par {interaction.user.mention}."
)
embed = discord.Embed(
title=f"Ticket {'locked' if ticket.locked else 'unlocked'}",
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): class CloseButton(discord.ui.Button):
def __init__(self): def __init__(self):
super().__init__( super().__init__(