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 af801fb..0000000 --- a/commandes/modmail.py +++ /dev/null @@ -1,620 +0,0 @@ -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 new file mode 100644 index 0000000..382e769 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,244 @@ +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 new file mode 100644 index 0000000..acd3fb5 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000..4483d02 --- /dev/null +++ b/tests/test_transcript.py @@ -0,0 +1,133 @@ +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 "