from __future__ import annotations import asyncio import contextlib import logging import os 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="Créer 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 système de tickets est désactivé.", ephemeral=True) return categories = config.categories if not categories: await interaction.response.send_message("Aucune catégorie configurée.", 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 catégorie :", 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="Sélectionnez une catégorie...", 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("Catégorie 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="Décris ton problème", style=discord.TextStyle.paragraph, placeholder="Explique brièvement 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("Catégorie 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) 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}>" container = discord.ui.Container() container.add_item(discord.ui.TextDisplay(mention)) container.add_item(discord.ui.Separator()) container.add_item( discord.ui.TextDisplay( f"## Ticket — {cat.name}\n" f"**Utilisateur :** {interaction.user.mention}\n" f"**Catégorie :** {cat.emoji} {cat.name}\n" f"**Raison :**\n{self.reason.value}" ) ) container.add_item(discord.ui.Separator()) container.add_item(discord.ui.TextDisplay("### Gestion")) container.add_item( discord.ui.ActionRow( ClaimButton(), TransferButton(), LockButton(), ) ) container.add_item(discord.ui.Separator()) container.add_item( discord.ui.ActionRow( AddMemberButton(), CloseButton(), ) ) view = discord.ui.LayoutView() view.add_item(container) await channel.send(view=view) await interaction.followup.send(f"Ticket créé : {channel.mention}", ephemeral=True) embed = discord.Embed( title="Ticket créé", description=f"**Catégorie :** {cat.name}\n**Utilisateur :** {interaction.user.mention}\n**Canal :** {channel.mention}", color=0x2F3136, timestamp=datetime.now(timezone.utc), ) await _send_log(guild, config, embed) class ClaimButton(discord.ui.Button): def __init__(self): super().__init__( label="Réclamer", style=discord.ButtonStyle.success, custom_id="ticket_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 réclamer 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 retiré son réclamation.", ephemeral=True ) else: await interaction.response.send_message( f"Ce ticket est déjà réclamé 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"Réclamé par {interaction.user.mention}", color=0x2F3136, timestamp=datetime.now(timezone.utc), ) await _send_log(interaction.guild, config, embed) container = discord.ui.Container() container.add_item(discord.ui.TextDisplay(f"## Ticket réclamé\nRéclamé 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", ) 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( "Sélectionnez le membre à ajouter :", view=AddMemberSelectView(), ephemeral=True, ) class AddMemberSelect(discord.ui.UserSelect): def __init__(self): super().__init__( placeholder="Sélectionnez 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 été ajouté au ticket.", view=None, ) embed = discord.Embed( title="Membre ajouté", description=f"{user.mention} a été ajouté par {interaction.user.mention}", color=0x2F3136, 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 TransferButton(discord.ui.Button): def __init__(self): super().__init__( label="Transférer", 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 transférer.", ephemeral=True) return await interaction.response.send_message( "Sélectionnez le staff :", view=TransferSelectView(), ephemeral=True, ) class TransferSelect(discord.ui.UserSelect): def __init__(self): super().__init__( placeholder="Sélectionnez 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 transféré à {user.mention}.", view=None, ) embed = discord.Embed( title="Ticket transféré", description=f"Transféré à {user.mention} par {interaction.user.mention}", color=0x2F3136, 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 priorité.", ephemeral=True) return await interaction.response.send_message( "Sélectionnez la priorité :", view=PrioritySelectView(), ephemeral=True, ) class PrioritySelect(discord.ui.Select): def __init__(self): super().__init__( placeholder="Sélectionnez la priorité...", 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=0x2F3136, 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="Verrouiller", 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 verrouiller/déverrouiller.", 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 = "Déverrouiller" if ticket.locked else "Verrouiller" await interaction.response.send_message( f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'} par {interaction.user.mention}." ) embed = discord.Embed( title=f"Ticket {'verrouillé' if ticket.locked else 'déverrouillé'}", description=f"Par {interaction.user.mention}", color=0x2F3136, 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 :** " 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): 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_content = generate_transcript(ticket, interaction.guild.name) transcript_file = discord.File( fp=__import__("io").BytesIO(html_content.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 fermé 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 été fermé.", file=discord.File( fp=__import__("io").BytesIO(html_content.encode()), filename=f"ticket_{ticket.channel_id}.html", ), ) break storage.save_ticket(ticket) embed = discord.Embed( title="Ticket fermé", description=f"**Fermé par :** {interaction.user.mention}\n**Catégorie :** {ticket.category_id}", color=0x2F3136, timestamp=datetime.now(timezone.utc), ) await _send_log(interaction.guild, config, embed) container = discord.ui.Container() container.add_item(discord.ui.TextDisplay("## Ticket fermé\nCe ticket sera supprimé 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 fermé par {interaction.user}")