diff --git a/bot.py b/bot.py index a7e7b58..5539e65 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("tickets.cog") + await self.load_extension("commandes.modmail") self.tree.on_error = self.on_app_command_error diff --git a/commandes/modmail.py b/commandes/modmail.py new file mode 100644 index 0000000..af801fb --- /dev/null +++ b/commandes/modmail.py @@ -0,0 +1,620 @@ +import contextlib +import logging + +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): + try: + category_key = self.values[0] + categories = cfg.get_categories() + cat = categories.get(category_key) + if not cat: + await interaction.response.send_message("Cette categorie n'existe plus.", ephemeral=True) + return + + 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))) + except Exception as e: + logging.getLogger("vision").error(f"CategorySelect callback error: {e}", exc_info=True) + if not interaction.response.is_done(): + await interaction.response.send_message(f"Erreur interne: `{e}`", ephemeral=True) + + +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() + if self.category_key not in cfg.get_categories(): + await interaction.followup.send("La categorie n'existe plus. Reessayez.", ephemeral=True) + return + 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()) + + container.add_item(discord.ui.ActionRow(CloseTicketButton())) + + view = discord.ui.LayoutView() + view.add_item(container) + + await channel.send(view=view) + + +class CloseTicketButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Fermer le ticket", + style=discord.ButtonStyle.danger, + custom_id="modmail_close_ticket", + ) + + async def callback(self, interaction: discord.Interaction): + cog = interaction.client.get_cog("ModMail") + if cog: + await cog._close_ticket(interaction.channel, interaction.user) + + +class OpenTicketButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Ouvrir le ticket", + style=discord.ButtonStyle.primary, + custom_id="modmail_quick_open", + ) + + async def callback(self, interaction: discord.Interaction): + 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]] = {} + + # --- 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 + + categories = cfg.get_categories() + if not categories: + with contextlib.suppress(discord.HTTPException): + await message.author.send( + "Le systeme de tickets n'est pas encore configure. " + "Un administrateur doit d'abord configurer les categories." + ) + 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']}**.") + ) + container.add_item(discord.ui.ActionRow(OpenTicketButton())) + + 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/tests/test_data.py b/tests/test_data.py deleted file mode 100644 index 382e769..0000000 --- a/tests/test_data.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -from tickets.data import ( - GuildTicketConfig, - TicketCategory, - TicketData, - TicketMessage, - TicketPriority, - TicketStatus, -) - - -class TestTicketStatus: - def test_open_value(self): - assert TicketStatus.OPEN.value == "open" - - def test_closed_value(self): - assert TicketStatus.CLOSED.value == "closed" - - def test_enum_inheritance(self): - assert issubclass(TicketStatus, str) - - -class TestTicketPriority: - def test_all_values(self): - assert TicketPriority.LOW.value == "low" - assert TicketPriority.NORMAL.value == "normal" - assert TicketPriority.HIGH.value == "high" - assert TicketPriority.URGENT.value == "urgent" - - -class TestTicketCategory: - def test_create_default(self): - cat = TicketCategory(id="test", name="Test") - assert cat.id == "test" - assert cat.name == "Test" - assert cat.emoji == "" - assert cat.description == "" - assert cat.staff_role_id is None - assert cat.discord_category_id is None - - def test_to_dict(self): - cat = TicketCategory( - id="support", - name="Support", - emoji="🎫", - description="Aide", - staff_role_id=123, - discord_category_id=456, - ) - d = cat.to_dict() - assert d["id"] == "support" - assert d["name"] == "Support" - assert d["emoji"] == "🎫" - assert d["description"] == "Aide" - assert d["staff_role_id"] == 123 - assert d["discord_category_id"] == 456 - - def test_from_dict(self): - d = { - "id": "support", - "name": "Support", - "emoji": "🎫", - "description": "Aide", - "staff_role_id": 123, - "discord_category_id": 456, - } - cat = TicketCategory.from_dict(d) - assert cat.id == "support" - assert cat.name == "Support" - assert cat.staff_role_id == 123 - - def test_from_dict_ignores_extra_keys(self): - d = {"id": "x", "name": "X", "unknown_key": "should be ignored"} - cat = TicketCategory.from_dict(d) - assert cat.id == "x" - - def test_roundtrip(self): - cat = TicketCategory(id="a", name="A", emoji="🔧", description="desc") - cat2 = TicketCategory.from_dict(cat.to_dict()) - assert cat2 == cat - - -class TestTicketMessage: - def test_create_minimal(self): - msg = TicketMessage(author_id=1, author_name="user", content="hello", timestamp="2026-01-01") - assert msg.attachments == [] - assert msg.is_staff is False - - def test_create_full(self): - msg = TicketMessage( - author_id=1, - author_name="user", - content="hello", - timestamp="2026-01-01", - attachments=["a.png"], - is_staff=True, - ) - assert msg.attachments == ["a.png"] - assert msg.is_staff is True - - -class TestTicketData: - def test_create_minimal(self): - t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="cat") - assert t.status == TicketStatus.OPEN - assert t.priority == TicketPriority.NORMAL - assert t.locked is False - assert t.claimed_by is None - assert t.messages == [] - assert t.reason == "" - - def test_to_dict(self): - t = TicketData( - channel_id=100, - guild_id=200, - user_id=300, - category_id="support", - status=TicketStatus.CLOSED, - priority=TicketPriority.HIGH, - claimed_by=999, - locked=True, - reason="test", - ) - d = t.to_dict() - assert d["channel_id"] == 100 - assert d["status"] == "closed" - assert d["priority"] == "high" - assert d["claimed_by"] == 999 - assert d["locked"] is True - assert d["reason"] == "test" - - def test_to_dict_with_messages(self): - t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="c") - t.messages.append(TicketMessage(author_id=1, author_name="a", content="hi", timestamp="t")) - d = t.to_dict() - assert len(d["messages"]) == 1 - assert d["messages"][0]["content"] == "hi" - - def test_from_dict(self): - d = { - "channel_id": 1, - "guild_id": 2, - "user_id": 3, - "category_id": "c", - "status": "closed", - "priority": "high", - "claimed_by": 99, - "locked": True, - "reason": "r", - "messages": [{"author_id": 1, "author_name": "a", "content": "hi", "timestamp": "t"}], - } - t = TicketData.from_dict(d) - assert t.status == TicketStatus.CLOSED - assert t.priority == TicketPriority.HIGH - assert t.claimed_by == 99 - assert t.locked is True - assert len(t.messages) == 1 - - def test_from_dict_legacy_keys(self): - d = { - "channel_id": 1, - "guild_id": 2, - "user_id": 3, - "category_id": "c", - "transcript_path": "/old/path.html", - "rating": 5, - "feedback": "ok", - } - t = TicketData.from_dict(d) - assert not hasattr(t, "transcript_path") - assert not hasattr(t, "rating") - assert not hasattr(t, "feedback") - - def test_roundtrip(self): - t = TicketData( - channel_id=10, - guild_id=20, - user_id=30, - category_id="cat", - status=TicketStatus.CLOSED, - priority=TicketPriority.URGENT, - claimed_by=50, - locked=True, - reason="test reason", - created_at="2026-01-01T00:00:00", - closed_at="2026-01-02T00:00:00", - ) - t.messages.append(TicketMessage(author_id=1, author_name="u", content="m", timestamp="t")) - t2 = TicketData.from_dict(t.to_dict()) - assert t2.channel_id == t.channel_id - assert t2.status == t.status - assert t2.priority == t.priority - assert t2.claimed_by == t.claimed_by - assert t2.locked == t.locked - assert len(t2.messages) == 1 - - -class TestGuildTicketConfig: - def test_create_default(self): - c = GuildTicketConfig(guild_id=1) - assert c.enabled is False - assert c.categories == [] - assert c.staff_role_id is None - assert c.log_channel_id is None - assert c.transcript_enabled is True - - def test_to_dict(self): - c = GuildTicketConfig( - guild_id=1, - enabled=True, - staff_role_id=100, - log_channel_id=200, - panel_channel_id=300, - panel_message_id=400, - ) - c.categories.append(TicketCategory(id="a", name="A")) - d = c.to_dict() - assert d["guild_id"] == 1 - assert d["enabled"] is True - assert d["staff_role_id"] == 100 - assert d["log_channel_id"] == 200 - assert len(d["categories"]) == 1 - - def test_from_dict(self): - d = { - "guild_id": 1, - "enabled": True, - "staff_role_id": 100, - "log_channel_id": 200, - "categories": [{"id": "a", "name": "A"}], - } - c = GuildTicketConfig.from_dict(d) - assert c.enabled is True - assert len(c.categories) == 1 - assert c.categories[0].id == "a" - - def test_roundtrip(self): - c = GuildTicketConfig(guild_id=42, enabled=True) - c.categories.append(TicketCategory(id="x", name="X", emoji="🔧")) - c2 = GuildTicketConfig.from_dict(c.to_dict()) - assert c2.guild_id == 42 - assert c2.enabled is True - assert c2.categories[0].id == "x" diff --git a/tests/test_storage.py b/tests/test_storage.py deleted file mode 100644 index acd3fb5..0000000 --- a/tests/test_storage.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import json - -import pytest - -from tickets import storage -from tickets.data import GuildTicketConfig, TicketCategory, TicketData - - -@pytest.fixture(autouse=True) -def clean_storage(tmp_path): - """Isolate storage in a temp dir and reset caches.""" - storage.TICKETS_DIR = tmp_path / "tickets" - storage._CONFIG_FILE = storage.TICKETS_DIR / "config.json" - storage._config_cache.clear() - storage._tickets_cache.clear() - yield - storage._config_cache.clear() - storage._tickets_cache.clear() - - -class TestGetConfig: - def test_returns_default_when_missing(self): - config = storage.get_config(guild_id=123) - assert config.guild_id == 123 - assert config.enabled is False - assert config.categories == [] - - def test_returns_same_object(self): - c1 = storage.get_config(guild_id=1) - c2 = storage.get_config(guild_id=1) - assert c1 is c2 - - def test_different_guilds_different_configs(self): - c1 = storage.get_config(guild_id=1) - c2 = storage.get_config(guild_id=2) - assert c1.guild_id != c2.guild_id - - -class TestSaveConfig: - def test_save_creates_file(self, tmp_path): - config = storage.get_config(guild_id=1) - config.enabled = True - storage.save_config(config) - assert storage._CONFIG_FILE.exists() - - def test_save_persists(self, tmp_path): - config = storage.get_config(guild_id=1) - config.enabled = True - config.categories.append(TicketCategory(id="a", name="A")) - storage.save_config(config) - - storage._config_cache.clear() - loaded = storage.get_config(guild_id=1) - assert loaded.enabled is True - assert len(loaded.categories) == 1 - assert loaded.categories[0].id == "a" - - def test_save_multiple_guilds(self): - c1 = storage.get_config(guild_id=1) - c1.enabled = True - storage.save_config(c1) - - c2 = storage.get_config(guild_id=2) - c2.enabled = False - storage.save_config(c2) - - storage._config_cache.clear() - assert storage.get_config(guild_id=1).enabled is True - assert storage.get_config(guild_id=2).enabled is False - - -class TestSaveAndGetTicket: - def test_get_missing_returns_none(self): - assert storage.get_ticket(999) is None - - def test_save_and_get(self): - ticket = TicketData(channel_id=100, guild_id=1, user_id=2, category_id="cat") - storage.save_ticket(ticket) - loaded = storage.get_ticket(100) - assert loaded is not None - assert loaded.channel_id == 100 - assert loaded.category_id == "cat" - - def test_save_persists_to_disk(self): - ticket = TicketData(channel_id=200, guild_id=1, user_id=2, category_id="c") - storage.save_ticket(ticket) - - storage._tickets_cache.clear() - loaded = storage.get_ticket(200) - assert loaded is not None - assert loaded.user_id == 2 - - def test_save_overwrites(self): - t1 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="old") - storage.save_ticket(t1) - - t2 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="new") - storage.save_ticket(t2) - - loaded = storage.get_ticket(300) - assert loaded.category_id == "new" - - def test_multiple_tickets(self): - t1 = TicketData(channel_id=400, guild_id=1, user_id=2, category_id="a") - t2 = TicketData(channel_id=401, guild_id=1, user_id=3, category_id="b") - storage.save_ticket(t1) - storage.save_ticket(t2) - assert storage.get_ticket(400).category_id == "a" - assert storage.get_ticket(401).category_id == "b" - - -class TestDeleteTicket: - def test_delete_removes_file(self): - ticket = TicketData(channel_id=500, guild_id=1, user_id=2, category_id="c") - storage.save_ticket(ticket) - assert storage.get_ticket(500) is not None - - storage.delete_ticket(500) - assert storage.get_ticket(500) is None - - def test_delete_nonexistent(self): - storage.delete_ticket(9999) - - def test_delete_clears_cache(self): - ticket = TicketData(channel_id=600, guild_id=1, user_id=2, category_id="c") - storage.save_ticket(ticket) - storage.delete_ticket(600) - assert 600 not in storage._tickets_cache diff --git a/tests/test_transcript.py b/tests/test_transcript.py deleted file mode 100644 index 4483d02..0000000 --- a/tests/test_transcript.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -from tickets.data import TicketData, TicketMessage -from tickets.transcript import _format_ts, generate_transcript - - -class TestFormatTs: - def test_valid_iso(self): - result = _format_ts("2026-01-15T10:30:00+00:00") - assert result == "15/01/2026 10:30:00" - - def test_valid_naive_iso(self): - result = _format_ts("2026-06-01T12:00:00") - assert result == "01/06/2026 12:00:00" - - def test_invalid_string_returns_as_is(self): - assert _format_ts("not-a-date") == "not-a-date" - - def test_empty_string(self): - assert _format_ts("") == "" - - def test_none_returns_none(self): - assert _format_ts(None) is None - - -def _make_ticket(**kwargs) -> TicketData: - defaults = dict(channel_id=123, guild_id=456, user_id=789, category_id="support") - defaults.update(kwargs) - return TicketData(**defaults) - - -class TestGenerateTranscript: - def test_returns_html(self): - result = generate_transcript(_make_ticket()) - assert result.startswith("") - assert "" in result - - def test_contains_channel_id(self): - result = generate_transcript(_make_ticket(channel_id=42)) - assert "Ticket #42" in result - - def test_empty_messages(self): - ticket = _make_ticket() - result = generate_transcript(ticket) - assert "Aucun message" in result - - def test_single_user_message(self): - ticket = _make_ticket() - ticket.messages.append( - TicketMessage(author_id=1, author_name="TestUser", content="Hello!", timestamp="2026-01-01T00:00:00") - ) - result = generate_transcript(ticket) - assert "TestUser" in result - assert "Hello!" in result - assert 'class="badge badge-staff"' not in result - - def test_staff_message_has_badge(self): - ticket = _make_ticket() - ticket.messages.append( - TicketMessage( - author_id=2, author_name="StaffUser", content="Reply", timestamp="2026-01-01T00:00:00", is_staff=True - ) - ) - result = generate_transcript(ticket) - assert "badge-staff" in result - assert "STAFF" in result - - def test_message_content_escaped(self): - ticket = _make_ticket() - ticket.messages.append( - TicketMessage(author_id=1, author_name="x", content="", timestamp="t") - ) - result = generate_transcript(ticket) - assert "