From 57b6f09dd7ffbf253565badb8d7bb8c1edc8aa36 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 20 Sep 2026 20:04:10 +0200 Subject: [PATCH] feat(ticket): rewrite ticket system from scratch inspired by Kuby 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 --- bot.py | 2 +- commandes/modmail.py | 612 ------------------------------------ tickets/__init__.py | 0 tickets/cog.py | 241 ++++++++++++++ tickets/data.py | 119 +++++++ tickets/storage.py | 86 +++++ tickets/transcript.py | 174 ++++++++++ tickets/ui.py | 284 +++++++++++++++++ utils/modmail_config.py | 115 ------- utils/modmail_transcript.py | 215 ------------- 10 files changed, 905 insertions(+), 943 deletions(-) delete mode 100644 commandes/modmail.py create mode 100644 tickets/__init__.py create mode 100644 tickets/cog.py create mode 100644 tickets/data.py create mode 100644 tickets/storage.py create mode 100644 tickets/transcript.py create mode 100644 tickets/ui.py delete mode 100644 utils/modmail_config.py delete mode 100644 utils/modmail_transcript.py diff --git a/bot.py b/bot.py index 5539e65..a7e7b58 100644 --- a/bot.py +++ b/bot.py @@ -26,7 +26,7 @@ class VisionBot(commands.Bot): async def setup_hook(self): logger.info("Chargement des cogs...") await self.load_extension("commandes.hello") - await self.load_extension("commandes.modmail") + await self.load_extension("tickets.cog") self.tree.on_error = self.on_app_command_error diff --git a/commandes/modmail.py b/commandes/modmail.py deleted file mode 100644 index c5516b4..0000000 --- a/commandes/modmail.py +++ /dev/null @@ -1,612 +0,0 @@ -import contextlib - -import discord -from discord import app_commands -from discord.ext import commands - -from utils import modmail_config as cfg -from utils.modmail_transcript import generate_transcript, save_transcript - - -class CategorySelect(discord.ui.Select): - def __init__(self, categories: dict): - options = [] - for key, cat in categories.items(): - options.append( - discord.SelectOption( - label=cat["name"], - value=key, - emoji=cat.get("emoji", ""), - description=cat.get("description", "")[:100], - ) - ) - super().__init__( - placeholder="Selectionne une categorie...", - options=options, - custom_id="modmail_category_select", - ) - - async def callback(self, interaction: discord.Interaction): - category_key = self.values[0] - categories = cfg.get_categories() - cat = categories.get(category_key, {}) - data = cfg.get_config() - guild = interaction.client.get_guild(data.get("guild_id")) - if not guild: - await interaction.response.send_message("Erreur: serveur introuvable.", ephemeral=True) - return - - user = interaction.user - existing = cfg.get_ticket_by_user(user.id) - if existing: - ch = guild.get_channel(existing["channel_id"]) - await interaction.response.send_message( - f"Tu as deja un ticket ouvert: {ch.mention if ch else 'inconnu'}", - ephemeral=True, - ) - return - - await interaction.response.send_modal(TicketModal(category_key, cat.get("name", category_key))) - - -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_key: str, category_name: str): - super().__init__() - self.category_key = category_key - self.category_name = category_name - - async def on_submit(self, interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - bot = interaction.client - data = cfg.get_config() - guild = bot.get_guild(data.get("guild_id")) - if not guild: - await interaction.followup.send("Erreur serveur.", ephemeral=True) - return - - category = guild.get_channel(data["category_id"]) if data.get("category_id") else None - staff_role = guild.get_role(data["staff_role_id"]) if data.get("staff_role_id") else None - - 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, - ), - } - if staff_role: - overwrites[staff_role] = discord.PermissionOverwrite( - view_channel=True, - send_messages=True, - read_message_history=True, - attach_files=True, - ) - - ticket_name = f"ticket-{interaction.user.name}" - ticket_channel = await guild.create_text_channel( - ticket_name, - category=category, - overwrites=overwrites, - topic=f"Ticket de {interaction.user} ({interaction.user.id}) -- {self.category_name}", - ) - - cfg.register_ticket(guild.id, interaction.user.id, ticket_channel.id, self.category_key) - - await _send_ticket_panel(ticket_channel, interaction.user, self.category_name, self.reason.value) - - cog = interaction.client.get_cog("ModMail") - if cog is not None: - cog._dm_messages.setdefault(interaction.user.id, []).append( - { - "author": interaction.user, - "content": self.reason.value, - "timestamp": discord.utils.utcnow(), - "attachments": [], - "is_staff": False, - } - ) - - with contextlib.suppress(discord.HTTPException): - await interaction.followup.send( - f"Ticket cree: {ticket_channel.mention}", - ephemeral=True, - ) - - log_ch = guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None - if log_ch: - await log_ch.send( - f"Ticket cree par {interaction.user.mention} dans {ticket_channel.mention} ({self.category_name})" - ) - - -async def _send_ticket_panel(channel, user, category_name, initial_message): - container = discord.ui.Container(accent_colour=discord.Colour.blurple()) - container.add_item( - discord.ui.TextDisplay( - f"## Ticket — {category_name}\n" - f"**Utilisateur:** {user.mention} (`{user.id}`)\n" - f"**Categorie:** {category_name}" - ) - ) - container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.TextDisplay(f"**Message initial:**\n{initial_message}")) - container.add_item(discord.ui.Separator()) - - close_button = discord.ui.Button( - label="Fermer le ticket", - style=discord.ButtonStyle.danger, - custom_id="modmail_close_ticket", - ) - container.add_item(discord.ui.ActionRow(close_button)) - - view = discord.ui.LayoutView() - view.add_item(container) - - await channel.send(view=view) - - -class PersistentCloseButton(discord.ui.View): - def __init__(self): - super().__init__(timeout=None) - - @discord.ui.button( - label="Fermer le ticket", - style=discord.ButtonStyle.danger, - custom_id="modmail_close_ticket", - ) - async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button): - cog = interaction.client.get_cog("ModMail") - if cog: - await cog._close_ticket(interaction.channel, interaction.user) - - -class PersistentQuickOpen(discord.ui.View): - def __init__(self): - super().__init__(timeout=None) - - @discord.ui.button( - label="Ouvrir le ticket", - style=discord.ButtonStyle.primary, - custom_id="modmail_quick_open", - ) - async def quick_open(self, interaction: discord.Interaction, button: discord.ui.Button): - categories = cfg.get_categories() - if len(categories) == 1: - key = list(categories.keys())[0] - cat = categories[key] - await interaction.response.send_modal(TicketModal(key, cat.get("name", key))) - else: - await interaction.response.send_message("Erreur: reconfigurer le modmail.", ephemeral=True) - - -class TicketCategorySelect(discord.ui.ChannelSelect): - def __init__(self): - super().__init__( - placeholder="Categorie des tickets", - channel_types=[discord.ChannelType.category], - custom_id="modmail_cfg_ticket_category", - ) - - async def callback(self, interaction: discord.Interaction): - cfg.set_category(interaction.guild_id, self.values[0].id) - await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) - - -class StaffRoleSelect(discord.ui.RoleSelect): - def __init__(self): - super().__init__( - placeholder="Role du staff", - custom_id="modmail_cfg_staff_role", - ) - - async def callback(self, interaction: discord.Interaction): - cfg.set_staff_role(interaction.guild_id, self.values[0].id) - await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) - - -class LogChannelSelect(discord.ui.ChannelSelect): - def __init__(self): - super().__init__( - placeholder="Canal de logs", - channel_types=[discord.ChannelType.text], - custom_id="modmail_cfg_log_channel", - ) - - async def callback(self, interaction: discord.Interaction): - cfg.set_log_channel(interaction.guild_id, self.values[0].id) - await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) - - -class ToggleButton(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="modmail_cfg_toggle", - ) - - async def callback(self, interaction: discord.Interaction): - data = cfg.get_config() - cfg.set_enabled(interaction.guild_id, not data.get("enabled", False)) - await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) - - -class ManageCategoriesButton(discord.ui.Button): - def __init__(self): - super().__init__( - label="Gerer les categories", - style=discord.ButtonStyle.primary, - custom_id="modmail_cfg_manage_cat", - ) - - async def callback(self, interaction: discord.Interaction): - await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) - - -class CategoryRemoveSelect(discord.ui.Select): - def __init__(self, categories: dict): - options = [ - discord.SelectOption( - label=v["name"], - value=k, - emoji=v.get("emoji") or None, - description=v.get("description", "")[:100] or None, - ) - for k, v in list(categories.items())[:25] - ] - super().__init__( - placeholder="Supprimer une categorie", - options=options, - custom_id="modmail_cfg_remove_cat", - ) - - async def callback(self, interaction: discord.Interaction): - cfg.remove_category(interaction.guild_id, self.values[0]) - await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) - - -class AddCategoryButton(discord.ui.Button): - def __init__(self): - super().__init__( - label="Ajouter une categorie", - style=discord.ButtonStyle.success, - custom_id="modmail_cfg_add_cat", - ) - - async def callback(self, interaction: discord.Interaction): - await interaction.response.send_modal(AddCategoryModal()) - - -class BackButton(discord.ui.Button): - def __init__(self): - super().__init__( - label="Retour", - style=discord.ButtonStyle.secondary, - custom_id="modmail_cfg_back", - ) - - async def callback(self, interaction: discord.Interaction): - await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) - - -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=50, 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): - cfg.add_category( - interaction.guild_id, - self.name.value, - self.emoji.value or "🎫", - self.description.value or "", - ) - await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) - - -def _fmt_channel(cid) -> str: - return f"<#{cid}>" if cid else "`non defini`" - - -def _fmt_role(rid) -> str: - return f"<@&{rid}>" if rid else "`non defini`" - - -class ModmailConfigView(discord.ui.LayoutView): - def __init__(self, guild: discord.Guild): - super().__init__(timeout=None) - data = cfg.get_config() - categories = cfg.get_categories() - enabled = data.get("enabled", False) - - cat_list = ( - "\n".join(f"- {v.get('emoji', '')} **{v['name']}**" for v in categories.values()) or "*Aucune categorie*" - ) - - container = discord.ui.Container( - accent_colour=discord.Colour.green() if enabled else discord.Colour.red(), - ) - container.add_item( - discord.ui.TextDisplay( - f"## Configuration Modmail\n" - f"**Statut :** {'🟢 Actif' if enabled else '🔴 Inactif'}\n" - f"**Categorie des tickets :** {_fmt_channel(data.get('category_id'))}\n" - f"**Role staff :** {_fmt_role(data.get('staff_role_id'))}\n" - f"**Canal de logs :** {_fmt_channel(data.get('log_channel_id'))}\n" - f"### Categories ({len(categories)})\n{cat_list}" - ) - ) - container.add_item(discord.ui.Separator()) - container.add_item(discord.ui.ActionRow(TicketCategorySelect())) - container.add_item(discord.ui.ActionRow(StaffRoleSelect())) - container.add_item(discord.ui.ActionRow(LogChannelSelect())) - container.add_item(discord.ui.ActionRow(ToggleButton(enabled), ManageCategoriesButton())) - self.add_item(container) - - -class ModmailCategoriesView(discord.ui.LayoutView): - def __init__(self, guild: discord.Guild): - super().__init__(timeout=None) - categories = cfg.get_categories() - - cat_list = ( - "\n".join( - f"- {v.get('emoji', '')} **{v['name']}**" + (f" — {v['description']}" if v.get("description") else "") - for v in categories.values() - ) - or "*Aucune categorie*" - ) - - container = discord.ui.Container(accent_colour=discord.Colour.blurple()) - container.add_item( - discord.ui.TextDisplay( - f"## Gestion des categories\n" - f"{cat_list}\n\n" - "*Selectionne une categorie pour la supprimer, ou ajoute-en une nouvelle.*" - ) - ) - container.add_item(discord.ui.Separator()) - if categories: - container.add_item(discord.ui.ActionRow(CategoryRemoveSelect(categories))) - container.add_item(discord.ui.ActionRow(AddCategoryButton(), BackButton())) - self.add_item(container) - - -class ModMail(commands.Cog): - """Systeme de modmail / tickets via DM.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self._dm_messages: dict[int, list[dict]] = {} - self.bot.add_view(PersistentCloseButton()) - self.bot.add_view(PersistentQuickOpen()) - - # --- LISTENERS --- - - @commands.Cog.listener() - async def on_message(self, message: discord.Message): - if message.author.bot: - return - if isinstance(message.channel, discord.DMChannel): - await self._handle_dm(message) - return - await self._relay_to_dm(message) - await self.bot.process_commands(message) - - async def _handle_dm(self, message: discord.Message): - data = cfg.get_config() - if not data.get("enabled"): - return - if not data.get("guild_id"): - return - - existing = cfg.get_ticket_by_user(message.author.id) - if existing: - guild = self.bot.get_guild(existing["guild_id"]) - if guild: - ch = guild.get_channel(existing["channel_id"]) - if ch: - await self._send_to_ticket(ch, message.author, message) - return - - await self._send_ticket_creation_panel(message) - - async def _send_ticket_creation_panel(self, message: discord.Message): - categories = cfg.get_categories() - if not categories: - await message.author.send("Aucune categorie de ticket configuree.") - return - - if len(categories) == 1: - key = list(categories.keys())[0] - cat = categories[key] - - container = discord.ui.Container(accent_colour=discord.Colour.blurple()) - container.add_item( - discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.") - ) - open_button = discord.ui.Button( - label=f"Ouvrir — {cat['name']}", - style=discord.ButtonStyle.primary, - custom_id=f"modmail_quick_open:{key}", - ) - container.add_item(discord.ui.ActionRow(open_button)) - - view = discord.ui.LayoutView() - view.add_item(container) - await message.author.send(view=view) - return - - container = discord.ui.Container(accent_colour=discord.Colour.blurple()) - container.add_item( - discord.ui.TextDisplay("## Ouvrir un ticket\nChoisis une categorie pour ouvrir un ticket avec le staff.") - ) - container.add_item(discord.ui.Separator()) - container.add_item(CategorySelect(categories)) - - view = discord.ui.LayoutView() - view.add_item(container) - - await message.author.send(view=view) - - async def _relay_to_dm(self, message: discord.Message): - ticket = cfg.get_ticket_by_channel(message.channel.id) - if not ticket: - return - - user_id = ticket["user_id"] - user = self.bot.get_user(user_id) - if not user: - return - - if not self._dm_messages.get(user_id): - self._dm_messages[user_id] = [] - - self._dm_messages[user_id].append( - { - "author": message.author, - "content": message.content, - "timestamp": message.created_at, - "attachments": list(message.attachments), - "is_staff": True, - } - ) - - with contextlib.suppress(discord.HTTPException): - embed = discord.Embed( - description=message.content or "*Pieces jointes*", - color=discord.Color.green(), - timestamp=message.created_at, - ) - embed.set_author( - name=str(message.author), - icon_url=message.author.display_avatar.url, - ) - embed.set_footer(text=f"Staff — {message.guild.name}" if message.guild else "Staff") - await user.send(embed=embed) - - for att in message.attachments: - with contextlib.suppress(discord.HTTPException): - await user.send(file=await att.to_file()) - - async def _send_to_ticket(self, channel, user, message): - if not self._dm_messages.get(user.id): - self._dm_messages[user.id] = [] - - self._dm_messages[user.id].append( - { - "author": user, - "content": message.content, - "timestamp": message.created_at, - "attachments": list(message.attachments), - "is_staff": False, - } - ) - - embed = discord.Embed( - description=message.content or "*Pieces jointes*", - color=discord.Color.blurple(), - timestamp=message.created_at, - ) - embed.set_author( - name=str(user), - icon_url=user.display_avatar.url, - ) - embed.set_footer(text=f"User ID: {user.id}") - await channel.send(embed=embed) - - for att in message.attachments: - await channel.send(file=await att.to_file()) - - # --- COMMANDES --- - - modmail_group = app_commands.Group(name="modmail", description="Systeme de modmail") - - @modmail_group.command(name="config", description="Panneau de configuration du modmail") - @app_commands.checks.has_permissions(manage_guild=True) - async def config_cmd(self, interaction: discord.Interaction): - cfg.set_guild_id(interaction.guild_id) - await interaction.response.send_message( - view=ModmailConfigView(interaction.guild), - ephemeral=True, - ) - - # --- CLOSE --- - - @commands.command(name="close") - async def close_prefix(self, ctx: commands.Context): - await self._close_ticket(ctx.channel, ctx.author) - - @app_commands.command(name="close", description="Fermer le ticket actuel") - async def close_slash(self, interaction: discord.Interaction): - await self._close_ticket(interaction.channel, interaction.user) - - async def _close_ticket(self, channel, user): - ticket = cfg.get_ticket_by_channel(channel.id) - if not ticket: - await channel.send("Ce n'est pas un ticket.") - return - - user_id = ticket["user_id"] - category = ticket.get("category", "inconnu") - dm_messages = self._dm_messages.get(user_id, []) - - transcript_html = generate_transcript( - dm_messages, - { - "category": category, - "user_id": user_id, - "channel_id": channel.id, - }, - ) - transcript_path = save_transcript(transcript_html, channel.id) - - dm_user = self.bot.get_user(user_id) - if dm_user: - with contextlib.suppress(discord.HTTPException): - await dm_user.send( - "Ton ticket a ete ferme. Voici le transcript:", - file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"), - ) - - data = cfg.get_config() - log_ch = channel.guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None - if log_ch: - await log_ch.send( - f"Ticket ferme par {user.mention} — {channel.name}", - file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"), - ) - - cfg.unregister_ticket(user_id) - self._dm_messages.pop(user_id, None) - - container = discord.ui.Container(accent_colour=discord.Colour.red()) - container.add_item( - discord.ui.TextDisplay(f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}") - ) - view = discord.ui.LayoutView() - view.add_item(container) - await channel.send(view=view) - - with contextlib.suppress(discord.HTTPException): - await channel.delete(reason=f"Ticket ferme par {user}") - - -async def setup(bot: commands.Bot): - await bot.add_cog(ModMail(bot)) diff --git a/tickets/__init__.py b/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tickets/cog.py b/tickets/cog.py new file mode 100644 index 0000000..7c62960 --- /dev/null +++ b/tickets/cog.py @@ -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)) diff --git a/tickets/data.py b/tickets/data.py new file mode 100644 index 0000000..6dee8c4 --- /dev/null +++ b/tickets/data.py @@ -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__}) diff --git a/tickets/storage.py b/tickets/storage.py new file mode 100644 index 0000000..3cf4f70 --- /dev/null +++ b/tickets/storage.py @@ -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() diff --git a/tickets/transcript.py b/tickets/transcript.py new file mode 100644 index 0000000..2e51ba2 --- /dev/null +++ b/tickets/transcript.py @@ -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 = 'STAFF' 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'
' + else: + att_html += f'
{html.escape(att.rsplit("/", 1)[-1])}
' + + messages_html += f""" +
+
+ {html.escape(msg.author_name)} + {badge} + {ts} +
+
{content}
+ {att_html} +
+ """ + + if not messages_html: + messages_html = '
Aucun message.
' + + return f""" + + + + +Transcript — Ticket #{ticket.channel_id} + + + +
+

Ticket #{ticket.channel_id}

+
+ Categorie: {html.escape(ticket.category_id)} + User: {ticket.user_id} + Cree: {_format_ts(ticket.created_at)} + {f"Ferme: {_format_ts(ticket.closed_at)}" if ticket.closed_at else ""} + Genere: {now} +
+
+
{messages_html}
+ + +""" diff --git a/tickets/ui.py b/tickets/ui.py new file mode 100644 index 0000000..81f44ff --- /dev/null +++ b/tickets/ui.py @@ -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}") diff --git a/utils/modmail_config.py b/utils/modmail_config.py deleted file mode 100644 index b840cc4..0000000 --- a/utils/modmail_config.py +++ /dev/null @@ -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) diff --git a/utils/modmail_transcript.py b/utils/modmail_transcript.py deleted file mode 100644 index f5d4a98..0000000 --- a/utils/modmail_transcript.py +++ /dev/null @@ -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 = 'STAFF' 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'
attachment
' - ) - else: - attachments_html += ( - f'
{html.escape(att.filename)}
' - ) - - messages_html += f""" -
-
- {html.escape(str(author))} - {html.escape(str(author))} - {staff_badge} - {timestamp} -
-
{content}
- {attachments_html} -
- """ - - if not messages_html: - messages_html = '
Aucun message dans ce ticket.
' - - return f""" - - - - -Transcript — Ticket #{ticket_info.get("channel_id", 0)} - - - -
-

Transcript — Ticket #{ticket_info.get("channel_id", 0)}

-
- Catégorie: {html.escape(ticket_info.get("category", "N/A"))} - Utilisateur: {ticket_info.get("user_id", "N/A")} - Généré le: {date_str} -
-
-
- {messages_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)