From eb07cb67a2091a153f632a35ef30dac7fe3d0903 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 3 Jul 2026 22:59:31 +0200 Subject: [PATCH 1/6] =?UTF-8?q?Fix:=20cr=C3=A9ation=20de=20cat=C3=A9gorie?= =?UTF-8?q?=20syst=C3=A8me=20de=20ticket=20kuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/ticket/__init__.py | 501 ++++++++++++++++++++------------ commandes/ticket/data/models.py | 4 + 2 files changed, 320 insertions(+), 185 deletions(-) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 4516f05..9dbaa87 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -41,6 +41,9 @@ from src.logger import kuby_logger # V2 RICH COMPONENTS LOADED - 14:10 kuby_logger.info("🎫 [TicketSystem] Chargement de la version V2 Rich Components...") +# Discord limite le nombre de composants par message ; 25 couvre les selects Discord. +MAX_PANEL_CATEGORIES = 25 + # Add a task for sending feedback reminders class FeedbackReminderTask: def __init__(self, bot): @@ -153,12 +156,8 @@ class TicketPanelView(disnake.ui.View): async def _create_callback(self, interaction: disnake.Interaction): """Affiche les catégories pour créer un ticket""" - # Reload config to ensure we have latest permissions/categories + self.storage.invalidate_cache(interaction.guild_id) self.config = self.storage.load_config(interaction.guild_id) - - # Reload config to ensure we have latest permissions/categories - self.config = self.storage.load_config(interaction.guild_id) - if not self.config.enabled: await interaction.response.send_message("Le système de tickets est désactivé.", ephemeral=True) @@ -167,45 +166,26 @@ class TicketPanelView(disnake.ui.View): if not self.config.categories: await interaction.response.send_message("Aucune catégorie configurée. Utilisez la configuration.", ephemeral=True) return - - # Build V2 Container for category selection - sections = [ - disnake.ui.TextDisplay(content="## 🎫 Créer un Ticket"), - disnake.ui.Separator(), - ] - - for cat in self.config.categories[:5]: - desc = cat.description or "Aucune description" - sections.append( - disnake.ui.Section( - f"**{cat.emoji} {cat.name}**\n{desc}", - accessory=disnake.ui.Button( - label=cat.name, - style=disnake.ButtonStyle.primary, - custom_id=f"catv2_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}" - ) - ) - ) - + + components = build_category_selection_components(self.config) try: await interaction.response.send_message( - components=[disnake.ui.Container(*sections)], + components=components, flags=disnake.MessageFlags(is_components_v2=True), ephemeral=True ) except Exception as e: import traceback traceback.print_exc() - # Fallback: texte simple avec view legacy if interaction.response.is_done(): await interaction.followup.send( - f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", + "**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", view=CategorySelectionView(self.bot, self.config), ephemeral=True ) else: await interaction.response.send_message( - f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", + "**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", view=CategorySelectionView(self.bot, self.config), ephemeral=True ) @@ -477,6 +457,104 @@ class TicketPanelView(disnake.ui.View): return stats +async def refresh_ticket_panel(bot, storage, guild_id: int) -> bool: + """Met à jour le message du panel de création de tickets s'il est enregistré.""" + config = storage.load_config(guild_id) + if not config.panel_channel_id or not config.panel_message_id: + return False + + try: + channel = bot.get_channel(config.panel_channel_id) + if channel is None: + channel = await bot.fetch_channel(config.panel_channel_id) + message = await channel.fetch_message(config.panel_message_id) + view = TicketPanelView(bot, config, storage) + await message.edit(components=view.get_components_v2()) + return True + except Exception as e: + kuby_logger.warning(f"[TicketSystem] Échec du rafraîchissement du panel: {e}") + return False + + +def build_category_selection_components(config: GuildTicketConfig) -> list: + """Construit le sélecteur de catégories (menu déroulant, jusqu'à 25 options). + + Un Container V2 ne peut contenir que 10 composants enfants ; une Section+Separator + par catégorie dépassait rapidement cette limite. Le menu déroulant affiche toutes + les catégories dans un seul composant. + """ + categories = config.categories[:MAX_PANEL_CATEGORIES] + if not categories: + return [] + + options = [] + for cat in categories: + option_kwargs = { + "label": cat.name[:100], + "value": str(cat.id)[:100], + } + description = (cat.description or "").strip() + if description: + option_kwargs["description"] = description[:100] + if cat.emoji: + option_kwargs["emoji"] = cat.emoji + options.append(disnake.SelectOption(**option_kwargs)) + + category_select = disnake.ui.StringSelect( + custom_id="ticket_category_select", + placeholder="Choisissez une catégorie...", + options=options, + min_values=1, + max_values=1, + ) + + return [ + disnake.ui.Container( + disnake.ui.TextDisplay( + content="# 📂 Créer un Ticket\n\n" + "Sélectionnez la catégorie correspondant à votre demande." + ), + disnake.ui.ActionRow(category_select), + ) + ] + + +async def open_ticket_for_category(bot, interaction: disnake.Interaction, config: GuildTicketConfig, category_id: str): + """Ouvre le modal de création de ticket pour la catégorie choisie.""" + category = config.get_category(category_id) + if not category: + await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True) + return + + storage = get_storage() + user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True) + + active_tickets = [] + for ticket in user_tickets: + if interaction.guild.get_channel(ticket.channel_id): + active_tickets.append(ticket) + else: + ticket.status = TicketStatus.CLOSED + storage.save_ticket(ticket) + + if len(active_tickets) >= config.max_tickets_per_user: + await interaction.response.send_message( + f"Vous avez atteint la limite de {config.max_tickets_per_user} tickets.", + ephemeral=True + ) + return + + if category.is_recruitment and category.questions: + modal = RecruitmentQuestionsModal(bot, config, category) + else: + modal = TicketPriorityModal(bot, config, category) + + if hasattr(modal, "title") and modal.title: + modal.title = str(modal.title)[:45] + + await interaction.response.send_modal(modal) + + class CategoryButton(disnake.ui.Button): def __init__(self, category, view_instance): super().__init__( @@ -502,7 +580,7 @@ class CategorySelectionView(disnake.ui.View): self.id_gen = int(datetime.now().timestamp()) # Ajouter boutons de catégories - for category in config.categories[:5]: + for category in config.categories[:MAX_PANEL_CATEGORIES]: self.add_item(CategoryButton(category, self)) def _is_staff(self, interaction: disnake.Interaction) -> bool: @@ -517,40 +595,7 @@ class CategorySelectionView(disnake.ui.View): async def _on_category_select(self, interaction: disnake.Interaction, category_id: str): """Handle category selection""" try: - - - category = self.config.get_category(category_id) - if not category: - await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True) - return - - storage = get_storage() - - # Check limits - user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True) - - active_tickets = [] - for t in user_tickets: - if interaction.guild.get_channel(t.channel_id): - active_tickets.append(t) - else: - # Automatically clean up phantom tickets - t.status = TicketStatus.CLOSED - storage.save_ticket(t) - - if len(active_tickets) >= self.config.max_tickets_per_user: - await interaction.response.send_message( - f"Vous avez atteint la limite de {self.config.max_tickets_per_user} tickets.", - ephemeral=True - ) - return - - # Check if this is a recruitment category - if category.is_recruitment and category.questions: - modal = RecruitmentQuestionsModal(self.bot, self.config, category) - else: - modal = TicketPriorityModal(self.bot, self.config, category) - await interaction.response.send_modal(modal) + await open_ticket_for_category(self.bot, interaction, self.config, category_id) except Exception as e: kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True) try: @@ -720,10 +765,13 @@ class RecruitmentQuestionsModal(disnake.ui.Modal): self.question_inputs = {} for i, question in enumerate(category.questions): + # Discord constraints: + # - placeholder length <= 100 + # We clamp the dynamic placeholder (questions come from config). input_field = disnake.ui.TextInput( - label=f"Question {i+1}", + label=f"Question {i+1}", # <= 45 by construction custom_id=f"recruitment_q_{i}", - placeholder=question, + placeholder=_clamp_text(question, 100, ""), required=True, max_length=1000, style=disnake.TextInputStyle.paragraph @@ -2050,6 +2098,7 @@ class CategoriesSetupView(disnake.ui.View): # Remove category self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id] self.storage.save_config(interaction.guild_id, self.config) + await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id) await interaction.response.send_message("✅ Catégorie supprimée!", ephemeral=True) @@ -2300,34 +2349,92 @@ class ConfigPanelView(disnake.ui.View): await interaction.response.send_modal(modal) +def _clamp_text(s: Any, max_len: int, default: str = "") -> str: + """Discord TextInput constraints: + - label: 1..45 + - placeholder: <=100 + """ + if s is None: + return default + s = str(s) + if max_len <= 0: + return "" + if len(s) > max_len: + s = s[:max_len] + return s + + class AddCategoryModal(disnake.ui.Modal): """Modal pour ajouter une catégorie""" def __init__(self, bot, storage, config: GuildTicketConfig): - self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True) - self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph) - self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5) + # label max 45, placeholder max 100 + name_label = _clamp_text("Nom", 45, "Nom") + name_placeholder = _clamp_text("Ex: Support", 100, "") + + desc_label = _clamp_text("Description", 45, "Description") + desc_placeholder = _clamp_text("Description...", 100, "") + + emoji_label = _clamp_text("Emoji", 45, "Emoji") + emoji_placeholder = _clamp_text("🎫", 100, "🎫") + + recruitment_label = _clamp_text("Recrutement? (oui/non)", 45, "Recrutement?") + recruitment_placeholder = _clamp_text("non", 100, "non") + + questions_label = _clamp_text( + "Questions recrutement (+ optionnel: ID salon réponses)", + 45, + "Questions recrutement" + ) + # This was the most likely culprit: multi-line placeholder very long + questions_placeholder = _clamp_text( + "Quel âge avez-vous? / Pourquoi vouloir rejoindre? / Expériences? / Optionnel: ID salon réponses: 123456789012345678", + 100, + "" + ) + + self.name_input = disnake.ui.TextInput( + label=name_label, + custom_id="add_cat_name", + placeholder=name_placeholder, + required=True, + ) + + self.desc_input = disnake.ui.TextInput( + label=desc_label, + custom_id="add_cat_desc", + placeholder=desc_placeholder, + required=False, + style=disnake.TextInputStyle.paragraph, + ) + + self.emoji_input = disnake.ui.TextInput( + label=emoji_label, + custom_id="add_cat_emoji", + placeholder=emoji_placeholder, + required=False, + max_length=5, + ) + # Recruitment fields self.is_recruitment_input = disnake.ui.TextInput( - label="Recrutement? (oui/non)", + label=recruitment_label, custom_id="add_cat_recruitment", - placeholder="non", + placeholder=recruitment_placeholder, required=False, - max_length=5 + max_length=5, ) + + # IMPORTANT: + # Disnake Modal has a maximum number of components. + # Keep this modal <= 5 components by merging "questions" + "responses channel id" into one input. self.questions_input = disnake.ui.TextInput( - label="Questions recrutement (une par ligne)", + label=questions_label, # <=45 enforced custom_id="add_cat_questions", - placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?", + placeholder=questions_placeholder, # <=100 enforced required=False, - style=disnake.TextInputStyle.paragraph - ) - self.responses_channel_input = disnake.ui.TextInput( - label="ID salon pour les réponses", - custom_id="add_cat_responses_channel", - placeholder="ID du salon staff où envoyer les réponses", - required=False, - max_length=20 + style=disnake.TextInputStyle.paragraph, ) + super().__init__( title="Ajouter une Catégorie", components=[ @@ -2336,9 +2443,9 @@ class AddCategoryModal(disnake.ui.Modal): self.emoji_input, self.is_recruitment_input, self.questions_input, - self.responses_channel_input ] ) + self.bot = bot self.storage = storage self.config = config @@ -2353,13 +2460,23 @@ class AddCategoryModal(disnake.ui.Modal): emoji_val = interaction.text_values.get("add_cat_emoji", "🎫") is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non") questions_val = interaction.text_values.get("add_cat_questions", "") - responses_channel_val = interaction.text_values.get("add_cat_responses_channel", "") is_recruitment = is_recruitment_val.lower() in ["oui", "yes", "o", "y", "1", "true"] - questions = [q.strip() for q in questions_val.split("\n") if q.strip()] if questions_val else [] + + # Parse: + # - questions are all non-empty lines + # - if the LAST non-empty line is a digit-only id => treat it as responses_channel_id + raw_lines = [ln.strip() for ln in (questions_val or "").split("\n") if ln.strip()] responses_channel_id = None - if responses_channel_val and responses_channel_val.isdigit(): - responses_channel_id = int(responses_channel_val) + + if raw_lines: + last = raw_lines[-1] + if last.isdigit(): + # treat last line as channel id, remove it from questions + responses_channel_id = int(last) + raw_lines = raw_lines[:-1] + + questions = raw_lines if raw_lines else [] category = TicketCategory( id=cat_id, @@ -2368,11 +2485,14 @@ class AddCategoryModal(disnake.ui.Modal): emoji=emoji_val, is_recruitment=is_recruitment, questions=questions, - responses_channel_id=responses_channel_id + responses_channel_id=responses_channel_id if is_recruitment else None ) - self.config.categories.append(category) - self.storage.save_config(interaction.guild_id, self.config) + self.storage.invalidate_cache(interaction.guild_id) + config = self.storage.load_config(interaction.guild_id) + config.categories.append(category) + self.storage.save_config(interaction.guild_id, config) + self.config = config recruitment_info = "" if is_recruitment: @@ -2380,7 +2500,18 @@ class AddCategoryModal(disnake.ui.Modal): if responses_channel_id: recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>" - await interaction.response.send_message( + await interaction.response.defer(ephemeral=True) + + if interaction.message: + try: + manager_view = CategoryManagerView(self.bot, self.storage, config) + await interaction.message.edit(components=manager_view.get_components_v2()) + except Exception as e: + kuby_logger.warning(f"[AddCategoryModal] Impossible de rafraîchir la liste des catégories: {e}") + + await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id) + + await interaction.followup.send( f"Catégorie **{name_val}** créée!{recruitment_info}", ephemeral=True ) @@ -2406,6 +2537,7 @@ class EditCategoryModal(disnake.ui.Modal): self.category.description = interaction.text_values.get("edit_cat_desc", "") self.category.emoji = interaction.text_values.get("edit_cat_emoji", "🎫") self.storage.save_config(interaction.guild_id, self.config) + await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id) container = disnake.ui.Container( disnake.ui.TextDisplay(content=f"✅ Catégorie **{self.category.name}** mise à jour !"), @@ -2507,17 +2639,22 @@ class CategoryManagerView(disnake.ui.View): description=desc_val, emoji=emoji_val ) - self.config.categories.append(category) - self.storage.save_config(interaction.guild_id, self.config) + self.storage.invalidate_cache(interaction.guild_id) + config = self.storage.load_config(interaction.guild_id) + config.categories.append(category) + self.storage.save_config(interaction.guild_id, config) + self.config = config + self.parent_view.config = config # Refresh parent view self.parent_view._add_category_select() + await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id) # On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed await interaction.response.edit_message( embed=self.parent_view._get_embed(), view=self.parent_view ) - await interaction.followup.send(f"Catégorie **{self.name.value}** créée!", ephemeral=True) + await interaction.followup.send(f"Catégorie **{name_val}** créée!", ephemeral=True) modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self) await interaction.response.send_modal(modal) @@ -2667,6 +2804,7 @@ class DeleteCategoryConfirmationView(disnake.ui.View): if self.category in self.config.categories: self.config.categories.remove(self.category) self.storage.save_config(interaction.guild_id, self.config) + await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id) self.parent_view._add_category_select() await interaction.response.edit_message( @@ -3191,31 +3329,11 @@ class TicketCommands(commands.Cog): # ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = catv2_{cat.id}_{snowflake} if custom_id.startswith("catv2_"): try: - # format: catv2__ parts = custom_id.split("_") - if len(parts) >= 3: - cat_id = "_".join(parts[1:-1]) - else: - cat_id = parts[1] if len(parts) > 1 else None - - cat_id = parts[1] - category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) - if not category: - await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) - return - - # Check if recruitment category with questions - if category.is_recruitment and category.questions: - modal = RecruitmentQuestionsModal(self.bot, config, category) - else: - modal = TicketPriorityModal(self.bot, config, category) - - # Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre - if hasattr(modal, "title") and modal.title: - modal.title = str(modal.title)[:45] - await inter.response.send_modal(modal) + cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None) + await open_ticket_for_category(self.bot, inter, config, cat_id) except Exception as e: - kuby_logger.error(f"[TicketSystem] cat| routing error: {e}", exc_info=True) + kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True) if not inter.response.is_done(): await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) @@ -3225,56 +3343,83 @@ class TicketCommands(commands.Cog): try: parts = str(custom_id).split("_") cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None) - - category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) - if not category: - await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) - return - - if category.is_recruitment and category.questions: - modal = RecruitmentQuestionsModal(self.bot, config, category) - else: - modal = TicketPriorityModal(self.bot, config, category) - - # Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre - if hasattr(modal, "title") and modal.title: - modal.title = str(modal.title)[:45] - await inter.response.send_modal(modal) + await open_ticket_for_category(self.bot, inter, config, cat_id) except Exception as e: - kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True) + kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) if not inter.response.is_done(): await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) elif custom_id == "ticket_create": - # Construction du layout de sélection Premium V2 - category_items = [ - disnake.ui.TextDisplay( - content="# 📂 Sélectionner une Catégorie - Omega Kube\n\n" - "Veuillez choisir la catégorie qui correspond au motif de votre demande pour ouvrir un ticket." + # Debug stockage/cache (pour diagnostiquer "catégorie non trouvée") + try: + kuby_logger.info(f"[TicketSystem][ticket_create] guild.id={getattr(inter.guild,'id',None)} guild_id={getattr(inter,'guild_id',None)}") + except Exception: + pass + + self.storage.invalidate_cache(inter.guild.id) + config = self.storage.load_config(inter.guild.id) + + try: + cats = getattr(config, "categories", None) or [] + # Affiche aussi des infos pour diagnostiquer le storage + storage_obj = self.storage + config_path = getattr(storage_obj, "config_path", None) + kuby_logger.info( + f"[TicketSystem][ticket_create] storage.config_path={config_path} " + f"guild_id={inter.guild.id} " + f"categories_len={len(cats)} categories_ids={[getattr(c,'id',None) for c in cats[:5]]}" ) - ] - - # Génération dynamique des sections avec Emojis et Descriptions configurées - for cat in config.categories[:5]: - cat_emoji = getattr(cat, "emoji", "") or "" - cat_desc = getattr(cat, "description", "") or getattr(cat, "desc", "") or "" - - category_items.append(disnake.ui.Separator(divider=True)) - category_items.append( - disnake.ui.Section( - f"### {cat_emoji} {cat.name}\n{cat_desc}" if cat_desc else f"### {cat_emoji} {cat.name}", - accessory=disnake.ui.Button( - label="Choisir", - style=disnake.ButtonStyle.blurple, - custom_id=f"cat_{cat.id}_select" - ) + except Exception: + pass + + if not config.categories: + # Au cas où cache désynchronisé => invalider aussi avec guild_id + try: + self.storage.invalidate_cache(inter.guild_id) + config = self.storage.load_config(inter.guild_id) + cats = getattr(config, "categories", None) or [] + kuby_logger.info(f"[TicketSystem][ticket_create] (after invalidate guild_id) categories_len={len(cats)}") + except Exception: + pass + + if not config.categories: + await inter.response.send_message( + "Aucune catégorie configurée (data/tickets/config.json semble vide pour cette guild). " + "Allez dans la configuration et ajoutez au moins 1 catégorie.", + ephemeral=True ) + return + + components = build_category_selection_components(config) + try: + await inter.response.send_message( + components=components, + flags=disnake.MessageFlags(is_components_v2=True), + ephemeral=True ) - - await inter.response.send_message( - components=[disnake.ui.Container(*category_items)], - ephemeral=True - ) + except Exception as e: + kuby_logger.error(f"[TicketSystem] ticket_create UI error: {e}", exc_info=True) + if inter.response.is_done(): + await inter.followup.send( + "**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", + view=CategorySelectionView(self.bot, config), + ephemeral=True + ) + else: + await inter.response.send_message( + "**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", + view=CategorySelectionView(self.bot, config), + ephemeral=True + ) + + elif custom_id == "ticket_category_select": + self.storage.invalidate_cache(inter.guild.id) + config = self.storage.load_config(inter.guild.id) + cat_id = inter.values[0] if inter.values else None + if not cat_id: + await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) + return + await open_ticket_for_category(self.bot, inter, config, cat_id) elif custom_id == "ticket_my_tickets": # Afficher les tickets de l'utilisateur user_tickets = self.storage.get_user_tickets(inter.guild.id, inter.user.id) @@ -3568,33 +3713,15 @@ class TicketCommands(commands.Cog): return elif custom_id.startswith("ticket_config_cat_del_"): - # Le bouton "✅ Confirmer" a custom_id="ticket_config_cat_del_confirm_title" - # et ne doit pas être traité comme un "legacy delete cat" (sinon cat_id=confirm_title). + # Ne pas traiter ticket_config_cat_del_confirm_title ici : + # la suppression est déjà gérée par DeleteCategoryConfirmationView.confirm_callback. + # Traiter à nouveau ici provoquait une double suppression (2 catégories supprimées). if custom_id == "ticket_config_cat_del_confirm_title": + # Juste reconstruire l'UI parent à partir de l'état actuel. config = self.storage.load_config(inter.guild.id) - if not config: - await inter.response.send_message("Erreur: config introuvable.", ephemeral=True) - return - - # ⚠️ Dans ce mode, on reconstruit la vue parent et on supprime - # la catégorie courante stockée dans le message via l’indexation. - # Ici, la catégorie exacte n'est pas encodée dans le custom_id, - # donc on supprime le dernier élément si la liste n’est pas vide (fallback). - # (Meilleur: corriger l’encodage en index au moment de créer le bouton.) - if not config.categories: - await inter.response.send_message("Aucune catégorie à supprimer.", ephemeral=True) - return - - # Fallback: supprimer le dernier (ce comportement évite le "rien ne se passe") - # et rend le bouton fonctionnel immédiatement. - category = config.categories[-1] - if category in config.categories: - config.categories.remove(category) - self.storage.save_config(inter.guild.id, config) - - # Rebuild UI - view = CategoryManagerView(self.bot, self.storage, config) - await inter.response.edit_message(components=view.get_components_v2()) + if config and config.categories is not None: + view = CategoryManagerView(self.bot, self.storage, config) + await inter.response.edit_message(components=view.get_components_v2()) return # Recharger config pour éviter les mismatch (panel vs état storage) @@ -3826,7 +3953,11 @@ class TicketCommands(commands.Cog): target = channel or inter.channel view = TicketPanelView(self.bot, config, self.storage) - await target.send(components=view.get_components_v2()) + panel_message = await target.send(components=view.get_components_v2()) + + config.panel_channel_id = target.id + config.panel_message_id = panel_message.id + self.storage.save_config(inter.guild.id, config) await inter.edit_original_response(content=f"Panel envoyé dans {target.mention}") diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index 7381f93..ec4248e 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -301,6 +301,7 @@ class GuildTicketConfig: claim_enabled: Whether claiming is enabled by default survey_enabled: Whether surveys are enabled by default panel_channel_id: Channel for the ticket panel + panel_message_id: Message ID of the ticket panel (for auto-refresh) welcome_enabled: Whether welcome messages are shown priority_enabled: Whether priority selection is enabled """ @@ -322,6 +323,7 @@ class GuildTicketConfig: claim_enabled: bool = True survey_enabled: bool = True panel_channel_id: Optional[int] = None + panel_message_id: Optional[int] = None welcome_enabled: bool = True priority_enabled: bool = True @@ -346,6 +348,7 @@ class GuildTicketConfig: "claim_enabled": self.claim_enabled, "survey_enabled": self.survey_enabled, "panel_channel_id": self.panel_channel_id, + "panel_message_id": self.panel_message_id, "welcome_enabled": self.welcome_enabled, "priority_enabled": self.priority_enabled } @@ -372,6 +375,7 @@ class GuildTicketConfig: claim_enabled=data.get("claim_enabled", True), survey_enabled=data.get("survey_enabled", True), panel_channel_id=data.get("panel_channel_id"), + panel_message_id=data.get("panel_message_id"), welcome_enabled=data.get("welcome_enabled", True), priority_enabled=data.get("priority_enabled", True) ) From cd0fc4c4882f25f794e76d1c90fb89abf128f33e Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 4 Jul 2026 19:31:10 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Impossibilit=C3=A9=20de=20fermer=20un=20tic?= =?UTF-8?q?ket=20pour=20les=20membre=20apr=C3=A8s=20prise=20en=20charge=20?= =?UTF-8?q?+=20correction=20affichage=20des=20invitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 13 +++++++++++++ commandes/invites.py | 9 +++++++-- commandes/ticket/__init__.py | 35 ++++++++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2613c55 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO - Ticket: fermeture restreinte après prise en charge + +- [ ] Mettre à jour la permission de fermeture côté joueur vs staff pour les tickets (mode général) : + - Joueur : uniquement si `TicketStatus.OPEN` + - Staff : autorisé même si `TicketStatus.CLAIMED` +- [x] Mettre à jour la permission de fermeture côté confirmation `close_confirm` (routeur `on_interaction`) avec la même règle. +- [x] Appliquer la même contrainte au mode recrutement (boutons `recruitment_close` / accept/refuse) : + - Joueur ne peut pas fermer si ticket déjà pris en charge (si applicable selon votre logique de "claim"). +- [x] Tester manuellement : + - Ticket OPEN => fermeture joueur OK + - Ticket CLAIMED => fermeture joueur refusée, staff OK + - Recrutement : fermeture conformes à la contrainte + diff --git a/commandes/invites.py b/commandes/invites.py index 3a2ecd6..fb80974 100644 --- a/commandes/invites.py +++ b/commandes/invites.py @@ -191,11 +191,16 @@ class Invites(commands.Cog): current_present = 0 for mid in invited_ids: - if guild.get_member(mid): + member = guild.get_member(mid) + if member is not None: + # "membres présents" doit refléter la présence réelle : on considère présent + # uniquement les membres qui sont encore sur le serveur. current_present += 1 else: kuby_logger.debug(f"[Invites] Membre invité {mid} non trouvé dans le serveur {guild_id}") - + + # Total = tous les invités enregistrés pour cet inviteur. + # "présents" = ceux qui sont encore sur le serveur (donc baisse quand quelqu'un quitte). return len(invited_ids), current_present except Exception as e: kuby_logger.error(f"[Invites] Erreur lors de la récupération des statistiques d'invitations pour {user_id}: {e}") diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 9dbaa87..95cbc0f 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -1053,9 +1053,13 @@ class TicketManagementView(disnake.ui.View): f"ticket_user={getattr(self.ticket,'user_id',None)}" ) # Vérifier si l'utilisateur peut fermer le ticket + # Règle voulue: + # - Staff: toujours autorisé + # - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN) can_close = ( - interaction.user.id == self.ticket.user_id or - self._is_staff(interaction) + self._is_staff(interaction) or ( + interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN + ) ) if not can_close: @@ -1270,9 +1274,13 @@ class CloseConfirmationView(disnake.ui.View): async def confirm_callback(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): """L'utilisateur confirme la fermeture""" # Vérifier les permissions + # Règle voulue: + # - Staff: toujours autorisé + # - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN) can_close = ( - interaction.user.id == self.ticket.user_id or - self._is_staff(interaction) + self._is_staff(interaction) or ( + interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN + ) ) if not can_close: @@ -3486,7 +3494,16 @@ class TicketCommands(commands.Cog): return # Vérifier que c'est du staff - if not is_staff(inter, config, category): + # Règle voulue: + # - Staff: toujours autorisé + # - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN) + # Ici on impose strictement staff (le bouton est staff dans la UI), + # et on protège aussi contre un éventuel usage côté user. + if ( + not is_staff(inter, config, category) and not ( + inter.user.id == ticket.user_id and ticket.status == TicketStatus.OPEN + ) + ): await inter.response.send_message("Réservé au staff.", ephemeral=True) return @@ -3562,9 +3579,13 @@ class TicketCommands(commands.Cog): await inter.response.send_message("Ticket non trouvé.", ephemeral=True) return + # Règle voulue: + # - Staff: toujours autorisé + # - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN) can_close = ( - inter.user.id == ticket.user_id or - is_staff(inter, config, config.get_category(ticket.category_id)) + is_staff(inter, config, config.get_category(ticket.category_id)) or ( + inter.user.id == ticket.user_id and ticket.status == TicketStatus.OPEN + ) ) if not can_close: await inter.response.send_message("Vous ne pouvez pas fermer ce ticket.", ephemeral=True) From 879355f79254f9a644f900cfeac700f32aff2ed6 Mon Sep 17 00:00:00 2001 From: Mathis Date: Mon, 6 Jul 2026 16:08:14 +0200 Subject: [PATCH 3/6] =?UTF-8?q?Ajout=20de=20la=20mon=C3=A9tisation=20V0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 24 ++-- commandes/absence_staff.py | 5 + commandes/backup.py | 2 + commandes/backup_restore.py | 2 + commandes/blacklist.py | 3 + commandes/bug_report.py | 3 + commandes/convocation.py | 2 + commandes/goodbye.py | 2 + commandes/invites.py | 2 + commandes/moderation.py | 4 + commandes/rapports.py | 4 + commandes/scan.py | 2 + commandes/security.py | 2 + commandes/sendbotmessages.py | 3 + commandes/snipe.py | 3 + commandes/staff_leaderboard.py | 2 + commandes/staff_ratings.py | 2 + commandes/taches.py | 2 + commandes/team_json.py | 1 + commandes/ticket/__init__.py | 7 +- commandes/ticket_whitelist.py | 3 + commandes/welcome.py | 3 + commandes/whitelist.py | 2 + test_paypal_logic.py | 210 +++++++++++++++++++++++++++++++++ test_premium.py | 200 +++++++++++++++++++++++++++++++ test_staff_ratings.py | 60 ---------- utils/premium.py | 205 ++++++++++++++++++++++++++++++++ whitelist_parteners.json | 5 + whitelist_partners.json | 5 + 29 files changed, 698 insertions(+), 72 deletions(-) create mode 100644 test_paypal_logic.py create mode 100644 test_premium.py delete mode 100644 test_staff_ratings.py create mode 100644 utils/premium.py create mode 100644 whitelist_parteners.json create mode 100644 whitelist_partners.json diff --git a/TODO.md b/TODO.md index 2613c55..c9be4b9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,15 @@ -# TODO - Ticket: fermeture restreinte après prise en charge +# TODO - Désactivation fonctionnalités premium (SKU) & correction accès /ticket -- [ ] Mettre à jour la permission de fermeture côté joueur vs staff pour les tickets (mode général) : - - Joueur : uniquement si `TicketStatus.OPEN` - - Staff : autorisé même si `TicketStatus.CLAIMED` -- [x] Mettre à jour la permission de fermeture côté confirmation `close_confirm` (routeur `on_interaction`) avec la même règle. -- [x] Appliquer la même contrainte au mode recrutement (boutons `recruitment_close` / accept/refuse) : - - Joueur ne peut pas fermer si ticket déjà pris en charge (si applicable selon votre logique de "claim"). -- [x] Tester manuellement : - - Ticket OPEN => fermeture joueur OK - - Ticket CLAIMED => fermeture joueur refusée, staff OK - - Recrutement : fermeture conformes à la contrainte +## Étape 1 — Compréhension +- [x] Identifier le check premium bloquant /ticket (utils/premium.py) +- [x] Vérifier où le check est appliqué (commandes/ticket/__init__.py) + +## Étape 2 — Mise en conformité +- [x] Désactivation temporaire via env var : DISABLE_PREMIUM_SKU_CHECK=true +- [ ] Ajouter un switch clair pour ré-activer facilement (déjà géré par la même env var) + + +## Étape 3 — Validation +- [ ] Lancer un lint/tests rapides ou démarrer le bot (si possible) +- [ ] Vérifier que /ticket répond sur le serveur bloqué (Omega Kube) diff --git a/commandes/absence_staff.py b/commandes/absence_staff.py index 79c7259..667cceb 100755 --- a/commandes/absence_staff.py +++ b/commandes/absence_staff.py @@ -9,6 +9,7 @@ try: except ImportError: from backports.zoneinfo import ZoneInfo from typing import Optional, Dict, Any +from utils.premium import check_premium_tier PARIS_TZ = ZoneInfo("Europe/Paris") DATE_FORMAT = "%d/%m/%Y %H:%M" @@ -259,12 +260,14 @@ class Absence(commands.Cog): self.check_absences.start() @commands.slash_command(name="absence", description="Déclarer une absence staff") + @check_premium_tier() async def absence(self, interaction: disnake.ApplicationCommandInteraction, superieur: Optional[disnake.Member] = commands.Param(None, description="Le supérieur hiérarchique à prévenir")): """Déclare une absence via un formulaire""" await interaction.response.send_modal(AbsenceModal(superieur)) @commands.slash_command(name="absence_config", description="Configurer le système d'absence (Admin)") + @check_premium_tier() @commands.has_permissions(administrator=True) async def absence_config(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre le panneau de configuration des absences""" @@ -350,6 +353,7 @@ class Absence(commands.Cog): print(f"Erreur lors du barrage du message d'absence: {e}") @commands.slash_command(name="fin_absence", description="Terminer son absence staff") + @check_premium_tier() async def fin_absence(self, interaction: disnake.ApplicationCommandInteraction): """Met fin à l'absence en cours""" all_abs = await load_absences() @@ -382,6 +386,7 @@ class Absence(commands.Cog): await interaction.response.send_message("✅ Ton absence a été retirée.", ephemeral=True) @commands.slash_command(name="liste_absences", description="Afficher la liste des absences staff en cours") + @check_premium_tier() async def liste_absences(self, interaction: disnake.ApplicationCommandInteraction): """Affiche les absences en cours sur le serveur""" all_abs = await load_absences() diff --git a/commandes/backup.py b/commandes/backup.py index 53c0293..cd4e91a 100755 --- a/commandes/backup.py +++ b/commandes/backup.py @@ -9,6 +9,7 @@ from typing import Dict, Any, List import disnake from disnake.ext import commands +from utils.premium import check_premium_tier BACKUPS_ROOT = "data/security_backups" # dossier de sortie des sauvegardes @@ -64,6 +65,7 @@ class Backup(commands.Cog): self.bot = bot @commands.slash_command(name="backup", description="Outils de sauvegarde du serveur") + @check_premium_tier() async def backup_group(self, interaction: disnake.ApplicationCommandInteraction): pass diff --git a/commandes/backup_restore.py b/commandes/backup_restore.py index f51cdbc..bfc2ac3 100755 --- a/commandes/backup_restore.py +++ b/commandes/backup_restore.py @@ -7,6 +7,7 @@ import shutil from typing import List import disnake from disnake.ext import commands +from utils.premium import check_premium_tier BACKUPS_ROOT = "data/security_backups" @@ -133,6 +134,7 @@ class BackupRestore(commands.Cog): await channel.send(msg["content"]) @commands.slash_command(name="granulaire", description="Restaure un élément précis d'une sauvegarde") + @check_premium_tier() async def restore_granulaire( self, interaction: disnake.ApplicationCommandInteraction, diff --git a/commandes/blacklist.py b/commandes/blacklist.py index 28d2288..01bef5d 100755 --- a/commandes/blacklist.py +++ b/commandes/blacklist.py @@ -4,6 +4,7 @@ import json import os import asyncio from datetime import datetime, timedelta +from utils.premium import check_premium_tier BLACKLIST_FILE = "data/blacklist.json" AGENTS_FILE = "data/agents.json" @@ -57,6 +58,7 @@ class Blacklist(commands.Cog): return str(user_id) in agents["agents"] @commands.slash_command(name="blacklist", description="Blacklist un utilisateur") + @check_premium_tier() async def blacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User, reason: str = "Aucune raison fournie"): if not self.is_agent(interaction.user.id): return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True) @@ -122,6 +124,7 @@ class Blacklist(commands.Cog): ) @commands.slash_command(name="unblacklist", description="Retirer un utilisateur de la blacklist") + @check_premium_tier() async def unblacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User): if not self.is_agent(interaction.user.id): return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True) diff --git a/commandes/bug_report.py b/commandes/bug_report.py index d2656d2..cd359a2 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -7,6 +7,7 @@ import json import os import asyncio from typing import Dict, Any, Optional +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -373,11 +374,13 @@ class BugReport(commands.Cog): return web.json_response({"status": "error", "message": str(e)}, status=500) @commands.slash_command(name="signaler_bug", description="Signaler un bug aux développeurs") + @check_premium_tier() async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): modal = BugReportModal(self) await interaction.response.send_modal(modal) @commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité") + @check_premium_tier() async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): modal = FeatureSuggestionModal(self) await interaction.response.send_modal(modal) diff --git a/commandes/convocation.py b/commandes/convocation.py index e88ae1a..5651e51 100755 --- a/commandes/convocation.py +++ b/commandes/convocation.py @@ -6,6 +6,7 @@ from typing import List, Optional, Dict import datetime import asyncio import logging +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") @@ -61,6 +62,7 @@ class Convocation(commands.Cog): save_json(CONV_BACKUPS_FILE, self.backups) @commands.slash_command(name="convocation", description="Gère les convocations de membres") + @check_premium_tier() @commands.has_permissions(manage_roles=True) async def convocation_group(self, interaction: disnake.ApplicationCommandInteraction): pass diff --git a/commandes/goodbye.py b/commandes/goodbye.py index 9b23c9b..159c057 100644 --- a/commandes/goodbye.py +++ b/commandes/goodbye.py @@ -7,6 +7,7 @@ import os import logging from PIL import Image, ImageDraw, ImageFont from datetime import datetime, timezone +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") @@ -285,6 +286,7 @@ class Goodbye(commands.Cog): return os.path.basename(banner_path) @commands.slash_command(name="setgoodbye", description="Définit le salon d'au revoir et les paramètres personnalisés") + @check_premium_tier() @commands.has_permissions(administrator=True) async def set_goodbye(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, server_name: str = None, message: str = None, banner_file: disnake.Attachment = None): # ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images) diff --git a/commandes/invites.py b/commandes/invites.py index fb80974..2ba74c7 100644 --- a/commandes/invites.py +++ b/commandes/invites.py @@ -5,6 +5,7 @@ import os import logging from typing import Dict, Optional import asyncio +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") @@ -234,6 +235,7 @@ class Invites(commands.Cog): kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {user.name}: {e}") @commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations") + @check_premium_tier() @commands.has_permissions(administrator=True) async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel): try: diff --git a/commandes/moderation.py b/commandes/moderation.py index 8d334b3..5af1372 100644 --- a/commandes/moderation.py +++ b/commandes/moderation.py @@ -5,6 +5,7 @@ import os from datetime import datetime, timedelta import re from src.logger import kuby_logger +from utils.premium import check_premium_tier class ModReasonModal(disnake.ui.Modal): def __init__(self, cog, action_type, member): @@ -254,6 +255,7 @@ class Moderation(commands.Cog): await interaction.response.send_message(components=components, ephemeral=True) @commands.slash_command(name="modpanel", description="Ouvre le panel de modération") + @check_premium_tier() @commands.has_permissions(moderate_members=True) async def modpanel(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member): if member.top_role >= interaction.user.top_role and interaction.user.id != interaction.guild.owner_id: @@ -291,6 +293,7 @@ class Moderation(commands.Cog): await interaction.response.send_message(components=components, ephemeral=True) @commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre") + @check_premium_tier() @commands.has_permissions(moderate_members=True) async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member): conn = sqlite3.connect(self.db_path); cursor = conn.cursor() @@ -316,6 +319,7 @@ class Moderation(commands.Cog): await interaction.response.send_message(components=components, ephemeral=True) @commands.slash_command(name="modconfig", description="Configuration de la modération") + @check_premium_tier() @commands.has_permissions(administrator=True) async def modconfig(self, interaction: disnake.ApplicationCommandInteraction): conn = sqlite3.connect(self.db_path); cursor = conn.cursor() diff --git a/commandes/rapports.py b/commandes/rapports.py index dd549d5..eac841f 100644 --- a/commandes/rapports.py +++ b/commandes/rapports.py @@ -10,6 +10,7 @@ except ImportError: import disnake from disnake.ext import commands, tasks +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") PARIS_TZ = ZoneInfo("Europe/Paris") @@ -906,6 +907,7 @@ class Rapports(commands.Cog): await self.bot.wait_until_ready() @commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)") + @check_premium_tier() @commands.has_permissions(administrator=True) async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None): guild_id = interaction.guild.id @@ -938,10 +940,12 @@ class Rapports(commands.Cog): await interaction.response.send_message(embed=embed, view=view, ephemeral=True) @commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport") + @check_premium_tier() async def rapport(self, interaction: disnake.ApplicationCommandInteraction): await interaction.response.send_modal(ReportModal(self)) @commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive") + @check_premium_tier() async def consulter_rapport( self, interaction: disnake.ApplicationCommandInteraction, diff --git a/commandes/scan.py b/commandes/scan.py index 1ccb2c9..e0ad5d1 100755 --- a/commandes/scan.py +++ b/commandes/scan.py @@ -5,6 +5,7 @@ import hashlib import os import json import re +from utils.premium import check_premium_tier MALWARE_DB = "malware.json" SUSPICIOUS_EXTENSIONS = [".exe", ".scr", ".bat", ".js", ".vbs", ".cmd"] @@ -59,6 +60,7 @@ class Scan(commands.Cog): self.bot = bot @commands.slash_command(name="scan", description="Scanner un fichier pour détecter un malware.") + @check_premium_tier() async def scan(self, interaction: disnake.ApplicationCommandInteraction, file: disnake.Attachment): await interaction.response.defer() # Permet de prendre un peu de temps pour le scan diff --git a/commandes/security.py b/commandes/security.py index 623e31e..ea6efff 100755 --- a/commandes/security.py +++ b/commandes/security.py @@ -3,6 +3,7 @@ from disnake.ext import commands import json import os from typing import Optional +from utils.premium import check_premium_tier # Chemins ABSOLUS des fichiers (basés sur le fichier courant) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -1029,6 +1030,7 @@ class Security(commands.Cog): name="security", description="🛡️ Ouvrir le panneau de sécurité Kuby" ) + @check_premium_tier() async def security(self, interaction: disnake.ApplicationCommandInteraction): """ Commande principale pour ouvrir l'interface de sécurité. diff --git a/commandes/sendbotmessages.py b/commandes/sendbotmessages.py index b2c9f56..f8bed23 100755 --- a/commandes/sendbotmessages.py +++ b/commandes/sendbotmessages.py @@ -1,5 +1,6 @@ import disnake from disnake.ext import commands +from utils.premium import check_premium_tier OWNER_ID = 971446412690722826 @@ -124,6 +125,7 @@ class SentBotMessages(commands.Cog): name="sentbotmessages", description="Le bot envoie un message dans un salon choisi." ) + @check_premium_tier() async def sentbotmessages( self, interaction: disnake.ApplicationCommandInteraction, @@ -154,6 +156,7 @@ class SentBotMessages(commands.Cog): name="annonce_proprietaires", description="Ouvrir le formulaire d'annonce pour les propriétaires." ) + @check_premium_tier() @commands.check(is_authorized) async def annonce_proprietaires( self, diff --git a/commandes/snipe.py b/commandes/snipe.py index 60b4b20..2acf177 100755 --- a/commandes/snipe.py +++ b/commandes/snipe.py @@ -2,6 +2,7 @@ import disnake from disnake.ext import commands from datetime import datetime from collections import deque +from utils.premium import check_premium_tier class SnipeCommands(commands.Cog): def __init__(self, bot): @@ -34,6 +35,7 @@ class SnipeCommands(commands.Cog): self.deleted_messages[channel_id].appendleft(message_data) @commands.slash_command(name="snipe", description="Voir les derniers messages supprimés d'un salon") + @check_premium_tier() async def snipe(self, interaction: disnake.ApplicationCommandInteraction, nombre: int = commands.Param(1, description="Nombre de messages à afficher (1-10, par défaut: 1)")): """Retrieve deleted messages from the current channel""" @@ -93,6 +95,7 @@ class SnipeCommands(commands.Cog): await interaction.response.send_message(embed=embed) @commands.slash_command(name="snipeclear", description="Effacer l'historique des messages supprimés pour ce salon") + @check_premium_tier() async def snipeclear(self, interaction: disnake.ApplicationCommandInteraction): """Clear the deleted messages history for the current channel""" channel_id = interaction.channel.id diff --git a/commandes/staff_leaderboard.py b/commandes/staff_leaderboard.py index d3b7b86..041e14a 100644 --- a/commandes/staff_leaderboard.py +++ b/commandes/staff_leaderboard.py @@ -4,6 +4,7 @@ from datetime import datetime from commandes.ticket.data.storage import get_storage from commandes.ticket.staff_analytics import StaffAnalytics +from utils.premium import check_premium_tier class StaffLeaderboard(commands.Cog): @@ -11,6 +12,7 @@ class StaffLeaderboard(commands.Cog): self.bot = bot @commands.slash_command(name="staff-leaderboard", description="Affiche le classement du staff") + @check_premium_tier() async def staff_leaderboard(self, interaction: disnake.ApplicationCommandInteraction): """Commande pour afficher le classement du staff""" await interaction.response.defer(ephemeral=False) diff --git a/commandes/staff_ratings.py b/commandes/staff_ratings.py index e44b0dd..6d7eafb 100644 --- a/commandes/staff_ratings.py +++ b/commandes/staff_ratings.py @@ -12,6 +12,7 @@ from collections import defaultdict from commandes.ticket.data.storage import get_storage from commandes.ticket.data.models import TicketStatus from commandes.ticket.staff_analytics import StaffAnalytics +from utils.premium import check_premium_tier class StaffRatings(commands.Cog): @@ -19,6 +20,7 @@ class StaffRatings(commands.Cog): self.bot = bot @commands.slash_command(name="staff-ratings", description="Affiche les évaluations du staff") + @check_premium_tier() async def staff_ratings(self, interaction: disnake.ApplicationCommandInteraction, team: str = commands.Param(None, description="Filtrer par équipe (optionnel)")): """Commande pour afficher les évaluations du staff""" diff --git a/commandes/taches.py b/commandes/taches.py index 8c89a54..e0e40a8 100644 --- a/commandes/taches.py +++ b/commandes/taches.py @@ -18,6 +18,7 @@ from utils.gestion_taches import ( PRIORITES, STATUTS ) from src.logger import kuby_logger +from utils.premium import check_premium_tier # ============================================================================ @@ -655,6 +656,7 @@ class TachesCog(commands.Cog): # ========================================================================== @commands.slash_command(name="taches", description="Ouvrir le menu de gestion des tâches") + @check_premium_tier() async def taches_command(self, inter: disnake.ApplicationCommandInteraction): """Commande principale pour accéder au menu des tâches.""" settings = await get_taches_settings(inter.guild.id) diff --git a/commandes/team_json.py b/commandes/team_json.py index 0ee9bbb..1a15073 100644 --- a/commandes/team_json.py +++ b/commandes/team_json.py @@ -162,6 +162,7 @@ class TeamJSON(commands.Cog): name="generate_team_json", description="Génère le JSON de l'équipe (admin only)" ) + @check_premium_tier() @commands.has_permissions(administrator=True) async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction): """Commande manuelle pour (re)générer le JSON et les avatars.""" diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 95cbc0f..c4dcf71 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -38,6 +38,7 @@ from .utils.formatters import TicketEmbedFormatter from .staff_analytics import StaffAnalytics, StaffStatsView from .utils.permissions import is_staff, is_whitelisted, can_access_config from src.logger import kuby_logger +from utils.premium import check_premium_tier # V2 RICH COMPONENTS LOADED - 14:10 kuby_logger.info("🎫 [TicketSystem] Chargement de la version V2 Rich Components...") @@ -3951,15 +3952,18 @@ class TicketCommands(commands.Cog): await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True) # === COMMANDES === - + @commands.slash_command(name="ticket", description="Ouvre le panel des tickets") + @check_premium_tier() async def ticket_main(self, inter: disnake.ApplicationCommandInteraction): + """Ouvre le panel des tickets""" config = self.storage.load_config(inter.guild.id) view = TicketPanelView(self.bot, config, self.storage) await inter.response.send_message(components=view.get_components_v2(), ephemeral=True) @commands.slash_command(name="ticketpanel", description="Envoie le panel de création de tickets dans un canal") + @check_premium_tier() @commands.has_permissions(administrator=True) async def ticket_panel(self, inter: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel = None): """Envoie le panel de création de tickets dans un canal""" @@ -3983,6 +3987,7 @@ class TicketCommands(commands.Cog): await inter.edit_original_response(content=f"Panel envoyé dans {target.mention}") @commands.slash_command(name="ticketconfig", description="Ouvre le panel de configuration complet du système de tickets") + @check_premium_tier() async def ticket_config(self, inter: disnake.ApplicationCommandInteraction): """Ouvre le panel de configuration complet du système de tickets""" await inter.response.defer(ephemeral=True) diff --git a/commandes/ticket_whitelist.py b/commandes/ticket_whitelist.py index 986ba65..61860dc 100644 --- a/commandes/ticket_whitelist.py +++ b/commandes/ticket_whitelist.py @@ -9,6 +9,7 @@ from typing import Optional from src.logger import kuby_logger import time from datetime import datetime, timezone +from utils.premium import check_premium_tier # Dictionnaire global pour suivre les renommages de salons # Format: {channel_id: [timestamp1, timestamp2, ...]} @@ -1586,6 +1587,7 @@ class Ticket(commands.Cog): self.bot.add_view(ClosedTicketView()) @commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.") + @check_premium_tier() async def ticket_whitelist(self, interaction: disnake.ApplicationCommandInteraction): """Envoie l'embed avec le bouton d'ouverture de ticket""" if not is_staff_or_admin(interaction.user, interaction.guild): @@ -1601,6 +1603,7 @@ class Ticket(commands.Cog): await interaction.response.send_message("✅ Embed des tickets envoyé.", ephemeral=True) @commands.slash_command(name="ticket_whitelist_config", description="Configurer le système de tickets whitelist (Staff/Admin)") + @check_premium_tier() async def ticket_whitelist_config(self, interaction: disnake.ApplicationCommandInteraction): """Interface de configuration des tickets en Containers V2""" if not is_staff_or_admin(interaction.user, interaction.guild): diff --git a/commandes/welcome.py b/commandes/welcome.py index b03db89..1c14d89 100644 --- a/commandes/welcome.py +++ b/commandes/welcome.py @@ -7,6 +7,7 @@ import os import logging import asyncio from PIL import Image, ImageDraw, ImageFont +from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") @@ -345,6 +346,7 @@ class Welcome(commands.Cog): return os.path.basename(banner_path) @commands.slash_command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière") + @check_premium_tier() @commands.has_permissions(administrator=True) async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None): # ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images) @@ -385,6 +387,7 @@ class Welcome(commands.Cog): await interaction.followup.send(error_message, ephemeral=True) @commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations") + @check_premium_tier() async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None): user = user or interaction.author await self._send_invitation_stats(interaction, user) diff --git a/commandes/whitelist.py b/commandes/whitelist.py index d21048e..f4f751f 100755 --- a/commandes/whitelist.py +++ b/commandes/whitelist.py @@ -3,6 +3,7 @@ from disnake.ext import commands import json import os from typing import List +from utils.premium import check_premium_tier # Chemins ABSOLUS des fichiers BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -38,6 +39,7 @@ class Whitelist(commands.Cog): self.save_whitelist() @commands.slash_command(name="whitelist", description="Gère la whitelist du serveur") + @check_premium_tier() async def whitelist_group(self, interaction: disnake.ApplicationCommandInteraction): """Groupe de commandes pour gérer la whitelist""" pass diff --git a/test_paypal_logic.py b/test_paypal_logic.py new file mode 100644 index 0000000..70c0e75 --- /dev/null +++ b/test_paypal_logic.py @@ -0,0 +1,210 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +import os +import json +import asyncio + +# On injecte les vrais IDs à 18 chiffres de ton .env pour le test +os.environ["STARTER_SKU_ID"] = "111111111111111111" +os.environ["MEDIUM_SKU_ID"] = "222222222222222222" +os.environ["LARGE_SKU_ID"] = "333333333333333333" + +from utils.premium import load_partners_whitelist, get_sku_ids, check_premium_tier, WHITELIST_PARTNERS_FILE + +class TestPremiumSystem(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + # Nettoyage du fichier JSON avant chaque test + if os.path.exists(WHITELIST_PARTNERS_FILE): + try: + os.remove(WHITELIST_PARTNERS_FILE) + except Exception: + pass + + def tearDown(self): + # Nettoyage du fichier JSON après chaque test + if os.path.exists(WHITELIST_PARTNERS_FILE): + try: + os.remove(WHITELIST_PARTNERS_FILE) + except Exception: + pass + + async def test_get_sku_ids(self): + """Vérifie que la lecture du .env convertit bien les chaînes en nombres entiers (int)""" + starter, medium, large = get_sku_ids() + self.assertEqual(starter, 111111111111111111) + self.assertEqual(medium, 222222222222222222) + self.assertEqual(large, 333333333333333333) + + async def test_load_partners_whitelist_empty(self): + """Vérifie que le système gère proprement un fichier whitelist vide sans crasher""" + partners = await load_partners_whitelist() + self.assertEqual(partners, []) + self.assertTrue(os.path.exists(WHITELIST_PARTNERS_FILE)) + + async def test_load_partners_whitelist_with_data(self): + """Vérifie le chargement des partenaires depuis le fichier JSON""" + with open(WHITELIST_PARTNERS_FILE, "w") as f: + json.dump({"partners": [123, 456]}, f) + partners = await load_partners_whitelist() + self.assertEqual(partners, [123, 456]) + + async def test_check_premium_tier_dm_fails(self): + """Vérifie que le bot bloque proprement les commandes exécutées en DM (hors serveur)""" + interaction = MagicMock() + interaction.guild = None + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once_with( + "❌ Cette commande ne peut être exécutée que sur un serveur Discord.", + ephemeral=True + ) + + async def test_check_premium_tier_whitelist_passes(self): + """Vérifie qu'un serveur dans la Whitelist passe sans condition, même sans SKU""" + with open(WHITELIST_PARTNERS_FILE, "w") as f: + json.dump({"partners": [12345]}, f) + + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 12345 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + interaction.response.send_message.assert_not_called() + + async def test_check_premium_tier_invalid_member_count_fails(self): + """Vérifie la sécurité (Fail-Safe) si le nombre de membres Discord renvoie une erreur""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = None + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once_with( + "❌ Impossible de vérifier la taille du serveur par sécurité. Veuillez réessayer plus tard.", + ephemeral=True + ) + + async def test_check_premium_tier_less_than_500_with_starter(self): + """Vérifie qu'un petit serveur (<500 membres) avec le SKU Starter est bien AUTORISÉ""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 100 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 111111111111111111 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_500_to_2500_with_starter_only_fails(self): + """Vérifie qu'un serveur moyen qui a grandi (>500 membres) est BLOQUÉ s'il n'a qu'un SKU Starter""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 1000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 111111111111111111 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once() + self.assertIn("Palier Medium", interaction.response.send_message.call_args[0][0]) + + async def test_check_premium_tier_500_to_2500_with_medium_passes(self): + """Vérifie qu'un serveur moyen (1000 membres) avec le SKU Medium est bien AUTORISÉ""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 1000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 222222222222222222 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_above_2500_with_large_passes(self): + """Vérifie qu'un très gros serveur (>2500 membres) avec le SKU Large est bien AUTORISÉ""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 3000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 333333333333333333 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_above_2500_with_medium_fails(self): + """Vérifie qu'un très gros serveur (>2500 membres) est BLOQUÉ s'il n'a qu'un SKU Medium""" + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 3000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 222222222222222222 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + self.assertIn("Palier Large", interaction.response.send_message.call_args[0][0]) + +if __name__ == "__main__": + unittest.main() diff --git a/test_premium.py b/test_premium.py new file mode 100644 index 0000000..5513de0 --- /dev/null +++ b/test_premium.py @@ -0,0 +1,200 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +import os +import json +import asyncio + +# Setup env variables before importing premium module +os.environ["STARTER_SKU_ID"] = "111111" +os.environ["MEDIUM_SKU_ID"] = "222222" +os.environ["LARGE_SKU_ID"] = "333333" + +from utils.premium import load_partners_whitelist, get_sku_ids, check_premium_tier, WHITELIST_PARTNERS_FILE + +class TestPremiumSystem(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + # Reset whitelist file before each test + if os.path.exists(WHITELIST_PARTNERS_FILE): + try: + os.remove(WHITELIST_PARTNERS_FILE) + except Exception: + pass + + def tearDown(self): + # Clean up whitelist file + if os.path.exists(WHITELIST_PARTNERS_FILE): + try: + os.remove(WHITELIST_PARTNERS_FILE) + except Exception: + pass + + async def test_get_sku_ids(self): + starter, medium, large = get_sku_ids() + self.assertEqual(starter, 111111) + self.assertEqual(medium, 222222) + self.assertEqual(large, 333333) + + async def test_load_partners_whitelist_empty(self): + partners = await load_partners_whitelist() + self.assertEqual(partners, []) + self.assertTrue(os.path.exists(WHITELIST_PARTNERS_FILE)) + + async def test_load_partners_whitelist_with_data(self): + with open(WHITELIST_PARTNERS_FILE, "w") as f: + json.dump({"partners": [123, 456]}, f) + partners = await load_partners_whitelist() + self.assertEqual(partners, [123, 456]) + + async def test_check_premium_tier_dm_fails(self): + interaction = MagicMock() + interaction.guild = None + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once_with( + "❌ Cette commande ne peut être exécutée que sur un serveur Discord.", + ephemeral=True + ) + + async def test_check_premium_tier_whitelist_passes(self): + with open(WHITELIST_PARTNERS_FILE, "w") as f: + json.dump({"partners": [12345]}, f) + + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 12345 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + interaction.response.send_message.assert_not_called() + + async def test_check_premium_tier_invalid_member_count_fails(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = None + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once_with( + "❌ Impossible de vérifier la taille du serveur par sécurité. Veuillez réessayer plus tard.", + ephemeral=True + ) + + async def test_check_premium_tier_less_than_500_with_starter(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 100 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + # Mock entitlements + entitlement = MagicMock() + entitlement.sku_id = 111111 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_500_to_2500_with_starter_only_fails(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 1000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 111111 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + interaction.response.send_message.assert_called_once() + self.assertIn("Palier Medium", interaction.response.send_message.call_args[0][0]) + + async def test_check_premium_tier_500_to_2500_with_medium_passes(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 1000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 222222 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_above_2500_with_large_passes(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 3000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 333333 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertTrue(result) + + async def test_check_premium_tier_above_2500_with_medium_fails(self): + interaction = MagicMock() + interaction.guild = MagicMock() + interaction.guild.id = 999 + interaction.guild.member_count = 3000 + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + entitlement = MagicMock() + entitlement.sku_id = 222222 + entitlement.is_active.return_value = True + interaction.entitlements = [entitlement] + + decorator = check_premium_tier() + predicate = decorator.predicate + + result = await predicate(interaction) + self.assertFalse(result) + self.assertIn("Palier Large", interaction.response.send_message.call_args[0][0]) + +if __name__ == "__main__": + unittest.main() diff --git a/test_staff_ratings.py b/test_staff_ratings.py deleted file mode 100644 index 7216672..0000000 --- a/test_staff_ratings.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the staff ratings implementation -""" - -import sys -import os - -# Add the project root to the path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -def test_imports(): - """Test that all modules can be imported correctly""" - try: - from commandes.ticket.staff_analytics import StaffAnalytics - print("✓ StaffAnalytics imported successfully") - - from commandes.ticket.feedback_view import FeedbackView - print("✓ FeedbackView imported successfully") - - from commandes.ticket import TicketPanelView - print("✓ TicketPanelView imported successfully") - - from commandes.staff_ratings import StaffRatings - print("✓ StaffRatings imported successfully") - - from commandes.staff_leaderboard import StaffLeaderboard - print("✓ StaffLeaderboard imported successfully") - - return True - except Exception as e: - print(f"✗ Import error: {e}") - return False - -def test_requirements(): - """Test that required packages are available""" - try: - import matplotlib - import plotly - print("✓ matplotlib and plotly imported successfully") - return True - except Exception as e: - print(f"✗ Package import error: {e}") - return False - -if __name__ == "__main__": - print("Testing staff ratings implementation...") - print("=" * 40) - - success = True - success &= test_imports() - success &= test_requirements() - - print("=" * 40) - if success: - print("✓ All tests passed!") - sys.exit(0) - else: - print("✗ Some tests failed!") - sys.exit(1) \ No newline at end of file diff --git a/utils/premium.py b/utils/premium.py new file mode 100644 index 0000000..ec9f440 --- /dev/null +++ b/utils/premium.py @@ -0,0 +1,205 @@ +import os +import json +import logging +import asyncio +import disnake +from disnake.ext import commands +from typing import Tuple, Optional, List + +from dotenv import load_dotenv + +# Logger instance +kuby_logger = logging.getLogger("KubyBot") + +# Project paths +UTILS_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(UTILS_DIR) + +# Explicitly load .env file from the project root +load_dotenv(os.path.join(PROJECT_ROOT, ".env")) + +# Whitelist file location +WHITELIST_PARTNERS_FILE = os.path.join(PROJECT_ROOT, "whitelist_partners.json") + +async def load_partners_whitelist() -> List[int]: + """Asynchronously loads the whitelist partners JSON file. + + Returns: + List[int]: A list of whitelisted guild IDs. + """ + def read_file(): + if not os.path.exists(WHITELIST_PARTNERS_FILE): + # Create a default empty file if it doesn't exist + try: + with open(WHITELIST_PARTNERS_FILE, "w", encoding="utf-8") as f: + json.dump({"partners": []}, f, indent=4) + return [] + except Exception as e: + kuby_logger.error(f"Failed to create default whitelist file: {e}") + return [] + try: + with open(WHITELIST_PARTNERS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("partners", []) + except (json.JSONDecodeError, IOError) as e: + kuby_logger.error(f"Failed to read whitelist file at {WHITELIST_PARTNERS_FILE}: {e}") + return [] + + return await asyncio.to_thread(read_file) + +def get_sku_ids() -> Tuple[Optional[int], Optional[int], Optional[int]]: + """Loads and returns the SKU IDs from the environment variables. + + Returns: + Tuple[Optional[int], Optional[int], Optional[int]]: Starter, Medium, and Large SKU IDs. + """ + def parse_env_int(key: str) -> Optional[int]: + val = os.getenv(key) + if not val: + return None + try: + return int(val.strip()) + except ValueError: + kuby_logger.error(f"Invalid environment variable format for {key}: '{val}' is not an integer.") + return None + + starter = parse_env_int("STARTER_SKU_ID") + medium = parse_env_int("MEDIUM_SKU_ID") + # Supports both LARGE_SKU_ID and MAX_SKU_ID + large = parse_env_int("LARGE_SKU_ID") or parse_env_int("MAX_SKU_ID") + return starter, medium, large + +def check_premium_tier(): + """A custom disnake command check that enforces premium tier limitations based on server size + and Discord Entitlements. + + IMPORTANT: + - Set DISABLE_PREMIUM_SKU_CHECK=true to bypass SKU entitlement checks (useful for dev/test). + """ + async def predicate(interaction: disnake.ApplicationCommandInteraction) -> bool: + # Dev/test bypass: allow commands even when SKU env vars aren't configured. + if os.getenv("DISABLE_PREMIUM_SKU_CHECK", "").lower() in {"1", "true", "yes", "y", "on"}: + kuby_logger.warning( + "[PREMIUM CHECK] Bypass enabled via DISABLE_PREMIUM_SKU_CHECK. " + f"guild_id={getattr(interaction.guild, 'id', None)}" + ) + return True + # Debugging Log + debug_guild_id = interaction.guild.id if interaction.guild else None + debug_member_count = interaction.guild.member_count if interaction.guild else None + try: + debug_entitlements = [{"sku_id": ent.sku_id, "active": ent.is_active()} for ent in interaction.entitlements] + except Exception as e: + debug_entitlements = f"Error: {e}" + + # Resolve SKU IDs from env for easier diagnosis + starter_id, medium_id, large_id = get_sku_ids() + kuby_logger.debug( + "[DEBUG PREMIUM CHECK] " + f"Guild ID: {debug_guild_id} | " + f"Member Count: {debug_member_count} | " + f"Entitlements: {debug_entitlements} | " + f"Env SKUs: starter={starter_id} medium={medium_id} large={large_id}" + ) + + # 1. Refuse access if the command was run in DMs + if interaction.guild is None: + kuby_logger.warning("Access Denied: Command run in DMs.") + await interaction.response.send_message( + "❌ Cette commande ne peut être exécutée que sur un serveur Discord.", + ephemeral=True + ) + return False + + guild_id = interaction.guild.id + + # 2. Check the Partners Whitelist + try: + partners = await load_partners_whitelist() + if guild_id in partners: + kuby_logger.info(f"Access Granted: Guild {guild_id} ({interaction.guild.name}) is in whitelist.") + return True + except Exception as e: + # Safe fail-closed pattern: Log error and continue to premium checks if whitelist loading crashed. + kuby_logger.error(f"Error checking partners whitelist for guild {guild_id}: {e}", exc_info=True) + + # 3. Retrieve and validate the guild member count + member_count = interaction.guild.member_count + if member_count is None or not isinstance(member_count, int) or member_count <= 0: + kuby_logger.error(f"Fail-Safe triggered: Invalid member count {member_count} for guild {guild_id}.") + await interaction.response.send_message( + "❌ Impossible de vérifier la taille du serveur par sécurité. Veuillez réessayer plus tard.", + ephemeral=True + ) + return False + + # 4. Check active entitlements and SKUs + try: + active_skus = {ent.sku_id for ent in interaction.entitlements if ent.is_active()} + except Exception as e: + kuby_logger.error(f"Fail-Closed: Error retrieving entitlements for guild {guild_id}: {e}", exc_info=True) + await interaction.response.send_message( + "❌ Erreur lors de la vérification de votre abonnement auprès de Discord. Veuillez réessayer plus tard.", + ephemeral=True + ) + return False + + # Identify required tier & allowed SKUs + + if member_count < 500: + required_tier_name = "Starter" + allowed_skus = [starter_id, medium_id, large_id] + elif 500 <= member_count <= 2500: + required_tier_name = "Medium" + allowed_skus = [medium_id, large_id] + else: + required_tier_name = "Large" + allowed_skus = [large_id] + + # Filter out unconfigured SKUs (None values) + allowed_skus = [sku for sku in allowed_skus if sku is not None] + + # If no valid SKU ID is configured in the environment for this tier, block access + if not allowed_skus: + kuby_logger.error( + f"Configuration Error: No SKU IDs configured in .env for tier '{required_tier_name}' " + f"(Starter: {starter_id}, Medium: {medium_id}, Large: {large_id}). Access blocked." + ) + await interaction.response.send_message( + "❌ Une erreur de configuration empêche l'accès à cette commande premium. " + "Veuillez contacter le support de l'association Omega Kube.", + ephemeral=True + ) + return False + + # Verify entitlement matching one of the allowed SKU IDs + has_valid_sku = any(sku in active_skus for sku in allowed_skus) + + if has_valid_sku: + kuby_logger.info( + f"Access Granted: Guild {guild_id} ({interaction.guild.name}) " + f"passed premium check (Members: {member_count}, Tier: {required_tier_name})." + ) + return True + + # 5. Block access and send polite ephemeral instruction message + kuby_logger.info( + f"Access Denied: Guild {guild_id} ({interaction.guild.name}) " + f"has {member_count} members but no valid SKU. Active SKUs: {active_skus}." + ) + + await interaction.response.send_message( + f"✨ **Fonctionnalité Premium** ✨\n\n" + f"Merci d'utiliser le bot **Kuby** ! Cette commande nécessite un abonnement actif.\n" + f"📊 **Statistiques de votre serveur :**\n" + f"- Nombre de membres : `{member_count}`\n" + f"- Palier requis : **Palier {required_tier_name}**\n\n" + f"💡 **Comment s'abonner ?**\n" + f"Vous pouvez vous abonner directement depuis le profil du bot Discord ou via l'App Directory.\n\n" + f"Si vous êtes un partenaire officiel de l'association **Omega Kube**, " + f"veuillez contacter un administrateur pour être ajouté à la Whitelist.", + ephemeral=True + ) + return False + + return commands.check(predicate) diff --git a/whitelist_parteners.json b/whitelist_parteners.json new file mode 100644 index 0000000..3d79113 --- /dev/null +++ b/whitelist_parteners.json @@ -0,0 +1,5 @@ +{ + "partners": [ + 1369669999345537145 + ] +} \ No newline at end of file diff --git a/whitelist_partners.json b/whitelist_partners.json new file mode 100644 index 0000000..3d79113 --- /dev/null +++ b/whitelist_partners.json @@ -0,0 +1,5 @@ +{ + "partners": [ + 1369669999345537145 + ] +} \ No newline at end of file From 9b4fbabbc57f4cea78c5321d301ffd6587621ef3 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 11 Jul 2026 18:54:27 +0200 Subject: [PATCH 4/6] =?UTF-8?q?Migration=20des=20int=C3=A9gration=20gitlab?= =?UTF-8?q?=20-->=20Forgejo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/gitlab_integration.py | 247 ++++++++++++-------------------- 1 file changed, 91 insertions(+), 156 deletions(-) diff --git a/commandes/gitlab_integration.py b/commandes/gitlab_integration.py index 70739a1..f964a5a 100644 --- a/commandes/gitlab_integration.py +++ b/commandes/gitlab_integration.py @@ -8,83 +8,64 @@ import aiohttp import disnake from disnake.ext import commands, tasks -# Environment variables for GitLab access -GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") -GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path +# Configuration des variables d'environnement pour Forgejo +FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN") +FORGEJO_OWNER = os.getenv("FORGEJO_OWNER") # Exemple: "Gameur" ou "Omega_Kube" +FORGEJO_REPO = os.getenv("FORGEJO_REPO") # Exemple: "kuby" +BASE_URL = "https://omegakubeserv.tail951d2f.ts.net/api/v1" -if not GITLAB_TOKEN or not GITLAB_PROJECT_ID: - raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID") +if not FORGEJO_TOKEN or not FORGEJO_OWNER or not FORGEJO_REPO: + raise RuntimeError("Forgejo variables (FORGEJO_TOKEN, FORGEJO_OWNER, FORGEJO_REPO) must be set.") -# Files to persist issue tracking information -ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") -LEGACY_REPORTS_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "gitlab_reports.json") +# Fichiers de persistance mis à jour pour Forgejo +ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "forgejo_issues.json") def load_issue_tracking() -> Dict[str, Any]: - """Charge le tracking et fusionne automatiquement avec gitlab_reports.json (migration)""" tracking = {} - - # Charge le fichier principal if os.path.exists(ISSUE_TRACK_FILE): try: with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: tracking = json.load(f) except Exception as e: - print(f"⚠️ Erreur chargement gitlab_issues.json: {e}") - - # Migration automatique depuis l'ancien système - if os.path.exists(LEGACY_REPORTS_FILE): - try: - with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f: - legacy_data = json.load(f) - for issue_iid, user_id in legacy_data.items(): - if issue_iid not in tracking: - tracking[issue_iid] = { - "requester_id": user_id, - "last_note_id": 0, - "type": "legacy_report" - } - print(f"🔄 Migration automatique: {len(legacy_data)} issues fusionnées depuis gitlab_reports.json") - except Exception as e: - print(f"⚠️ Erreur migration gitlab_reports.json: {e}") - + print(f"⚠️ Erreur chargement forgejo_issues.json: {e}") return tracking def save_issue_tracking(data: Dict[str, Any]) -> None: with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) -class GitLabIntegration(commands.Cog): - """Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added.""" +class ForgejoIntegration(commands.Cog): + """Cog fournissant des commandes slash pour créer des tickets sur Forgejo et notifier les utilisateurs.""" def __init__(self, bot: commands.Bot): self.bot = bot - self.issue_data: Dict[str, Any] = load_issue_tracking() # key: issue_iid (str) -> {"requester_id": int, "last_note_id": int} - self.check_new_notes.start() + self.issue_data: Dict[str, Any] = load_issue_tracking() + self.check_new_comments.start() def cog_unload(self): - self.check_new_notes.cancel() + self.check_new_comments.cancel() - async def _create_gitlab_issue(self, title: str, description: str, labels: List[str], requester_id: int = None) -> Optional[Dict[str, Any]]: - """Crée une issue sur GitLab et la track automatiquement.""" - url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" + async def _create_forgejo_issue(self, title: str, description: str, labels: List[str], requester_id: int = None) -> Optional[Dict[str, Any]]: + """Crée un ticket sur Forgejo et le track automatiquement.""" + url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues" headers = { - "PRIVATE-TOKEN": GITLAB_TOKEN, + "Authorization": f"token {FORGEJO_TOKEN}", "Content-Type": "application/json", } payload = { "title": title, - "description": description, - "labels": ",".join(labels), + "body": description, # GitLab utilisait 'description', Forgejo utilise 'body' + "labels": labels, # Forgejo accepte directement une liste ['bug', 'bot'] } try: async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=payload) as resp: resp.raise_for_status() result = await resp.json() - # Track automatiquement la nouvelle issue + if requester_id: - issue_iid = str(result["iid"]) - self.issue_data[issue_iid] = { + issue_number = str(result["number"]) # 'iid' devient 'number' + self.issue_data[issue_number] = { "requester_id": requester_id, "last_note_id": 0, "type": "discord-command" @@ -92,186 +73,140 @@ class GitLabIntegration(commands.Cog): save_issue_tracking(self.issue_data) return result except Exception as e: - print(f"❌ Erreur création issue GitLab: {e}") + print(f"❌ Erreur création issue Forgejo: {e}") return None - async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: - """Fetch all notes (comments) for a given issue.""" - url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes" - headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} + async def _get_issue_comments(self, issue_number: int) -> List[Dict[str, Any]]: + """Récupère tous les commentaires d'un ticket spécifique.""" + url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue_number}/comments" + headers = {"Authorization": f"token {FORGEJO_TOKEN}"} async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as resp: resp.raise_for_status() return await resp.json() - async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]): - """Send a private DM to the requester informing them of a new comment.""" + async def _notify_requester(self, requester_id: int, issue_number: int, comment: Dict[str, Any]): + """Envoie un DM privé à l'utilisateur lors d'un nouveau commentaire.""" user = await self.bot.fetch_user(requester_id) - author = note.get("author", {}).get("name", "Someone") - body = note.get("body", "") + author = comment.get("user", {}).get("username", "Quelqu'un") # 'author.name' devient 'user.username' + body = comment.get("body", "") + embed = disnake.Embed( - title=f"Nouveau commentaire sur votre demande GitLab (#{issue_iid})", + title=f"Nouveau commentaire sur votre demande Forgejo (#{issue_number})", description=body, - color=disnake.Color.blurple(), - timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc), + color=disnake.Color.brand_green(), + timestamp=datetime.fromisoformat(comment["created_at"].replace("Z", "+00:00")), ) embed.set_footer(text=f"Commentaire de {author}") try: await user.send(embed=embed) except disnake.HTTPException: - # The user may have DMs disabled; ignore silently. pass @tasks.loop(minutes=1) - async def check_new_notes(self): - """Vérifie TOUS les nouveaux commentaires (pas seulement le dernier) et notifie.""" - for issue_iid_str, info in list(self.issue_data.items()): - issue_iid = int(issue_iid_str) + async def check_new_comments(self): + """Boucle de vérification des commentaires Forgejo.""" + for issue_num_str, info in list(self.issue_data.items()): + issue_number = int(issue_num_str) last_known_id = info.get("last_note_id", 0) requester_id = info.get("requester_id") try: - notes = await self._get_issue_notes(issue_iid) - if not notes: + comments = await self._get_issue_comments(issue_number) + if not comments: continue - # ⚡ NOUVEAU: Notifie TOUS les commentaires manquants, pas seulement le dernier - missing_notes = [n for n in notes if n["id"] > last_known_id] - if missing_notes: - for note in missing_notes: - await self._notify_requester(requester_id, issue_iid, note) - # Met à jour avec le dernier ID vu - new_last_id = max(n["id"] for n in notes) - self.issue_data[issue_iid_str]["last_note_id"] = new_last_id + missing_comments = [c for c in comments if c["id"] > last_known_id] + if missing_comments: + for comment in missing_comments: + await self._notify_requester(requester_id, issue_number, comment) + + new_last_id = max(c["id"] for c in comments) + self.issue_data[issue_num_str]["last_note_id"] = new_last_id except Exception as e: - print(f"⚠️ Erreur vérification issue #{issue_iid}: {e}") + print(f"⚠️ Erreur vérification issue #{issue_number}: {e}") continue save_issue_tracking(self.issue_data) - @commands.slash_command(name="recuperer_suivi_issues", description="Récupère le suivi des commentaires après un incident") + @commands.slash_command(name="recuperer_suivi_issues", description="Récupère le suivi des commentaires Forgejo après un incident") async def recuperer_suivi(self, inter: disnake.ApplicationCommandInteraction): - """Reconstruit gitlab_issues.json avec les derniers commentaires.""" await inter.response.defer(ephemeral=True) updated_count = 0 - for issue_iid_str, info in list(self.issue_data.items()): - issue_iid = int(issue_iid_str) + for issue_num_str, info in list(self.issue_data.items()): + issue_number = int(issue_num_str) try: - notes = await self._get_issue_notes(issue_iid) - if notes: - latest_id = max(n["id"] for n in notes) + comments = await self._get_issue_comments(issue_number) + if comments: + latest_id = max(c["id"] for c in comments) if latest_id > info.get("last_note_id", 0): - self.issue_data[issue_iid_str]["last_note_id"] = latest_id + self.issue_data[issue_num_str]["last_note_id"] = latest_id updated_count += 1 except Exception as e: - print(f"⚠️ Erreur récupération issue #{issue_iid}: {e}") + print(f"⚠️ Erreur récupération issue #{issue_number}: {e}") continue save_issue_tracking(self.issue_data) await inter.edit_original_message( - content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues. " - f"Fichier `{ISSUE_TRACK_FILE}` mis à jour." + content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues." ) - # ---------- Slash commands ---------- @commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet") async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=FeatureModal(self)) - @commands.slash_command(name="signaler_bug", description="Signaler un bug") + @commands.slash_command(name="signaler_bug", description="Signaler un bug sur Kuby") async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction): await inter.response.send_modal(modal=BugModal(self)) -# ---------- Modals ---------- + +# ---------- Modals Acteurs ---------- class FeatureModal(disnake.Modal): - def __init__(self, cog: GitLabIntegration): + def __init__(self, cog: ForgejoIntegration): components = [ - disnake.ui.TextInput( - label="Titre de la fonctionnalité", - custom_id="title", - style=disnake.TextInputStyle.short, - max_length=100, - required=True, - ), - disnake.ui.TextInput( - label="Description détaillée", - custom_id="description", - style=disnake.TextInputStyle.paragraph, - max_length=2000, - required=True, - ), + disnake.ui.TextInput(label="Titre de la fonctionnalité", custom_id="title", max_length=100, required=True), + disnake.ui.TextInput(label="Description détaillée", custom_id="description", style=disnake.TextInputStyle.paragraph, max_length=2000, required=True), ] - super().__init__( - title="Suggestion de fonctionnalité", - custom_id="feature_modal", - components=components, - ) + super().__init__(title="Suggestion de fonctionnalité", custom_id="feature_modal", components=components) self.cog = cog async def callback(self, inter: disnake.ModalInteraction): title = inter.text_values["title"] description = inter.text_values["description"] await inter.response.defer(ephemeral=True) - try: - issue = await self.cog._create_gitlab_issue( - title=title, - description=description, - labels=["feature", "discord-bot"], - requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto - ) - if issue: - await inter.edit_original_message( - content=f"✅ Fonctionnalité créée : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue['iid']})" - ) - else: - await inter.edit_original_message(content="❌ Erreur lors de la création de la fonctionnalité.") - except Exception as e: - await inter.edit_original_message(content=f"❌ Erreur lors de la création de la fonctionnalité : {e}") + + issue = await self.cog._create_forgejo_issue( + title=title, description=description, labels=["feature", "discord-bot"], requester_id=inter.author.id + ) + if issue: + url = f"https://omegakubeserv.tail951d2f.ts.net/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue['number']}" + await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}]({url})") + else: + await inter.edit_original_message(content="❌ Erreur lors de la création sur Forgejo.") + class BugModal(disnake.Modal): - def __init__(self, cog: GitLabIntegration): + def __init__(self, cog: ForgejoIntegration): components = [ - disnake.ui.TextInput( - label="Titre du bug", - custom_id="title", - style=disnake.TextInputStyle.short, - max_length=100, - required=True, - ), - disnake.ui.TextInput( - label="Description et étapes pour reproduire", - custom_id="description", - style=disnake.TextInputStyle.paragraph, - max_length=2000, - required=True, - ), + disnake.ui.TextInput(label="Titre du bug", custom_id="title", max_length=100, required=True), + disnake.ui.TextInput(label="Description et étapes", style=disnake.TextInputStyle.paragraph, custom_id="description", max_length=2000, required=True), ] - super().__init__( - title="Signalement de bug", - custom_id="bug_modal", - components=components, - ) + super().__init__(title="Signalement de bug", custom_id="bug_modal", components=components) self.cog = cog async def callback(self, inter: disnake.ModalInteraction): title = inter.text_values["title"] description = inter.text_values["description"] await inter.response.defer(ephemeral=True) - try: - issue = await self.cog._create_gitlab_issue( - title=title, - description=description, - labels=["bug", "discord-bot"], - requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto - ) - if issue: - await inter.edit_original_message( - content=f"✅ Bug signalé : [{title}](https://gitlab.com/{GITLAB_PROJECT_ID}/-/issues/{issue['iid']})" - ) - else: - await inter.edit_original_message(content="❌ Erreur lors du signalement du bug.") - except Exception as e: - await inter.edit_original_message(content=f"❌ Erreur lors du signalement du bug : {e}") + + issue = await self.cog._create_forgejo_issue( + title=title, description=description, labels=["bug", "discord-bot"], requester_id=inter.author.id + ) + if issue: + url = f"https://omegakubeserv.tail951d2f.ts.net/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue['number']}" + await inter.edit_original_message(content=f"✅ Bug signalé : [{title}]({url})") + else: + await inter.edit_original_message(content="❌ Erreur lors du signalement sur Forgejo.") def setup(bot: commands.Bot): - bot.add_cog(GitLabIntegration(bot)) + bot.add_cog(ForgejoIntegration(bot)) \ No newline at end of file From 0e6a14dd106bb150c11f7d9996755f9fef72b4aa Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 11 Jul 2026 19:49:40 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Mise=20=C3=A0=20jour=20:=20Passage=20des=20?= =?UTF-8?q?issues=20et=20du=20client=20vers=20Forgejo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serveur_flask.py | 65 +++++++---------- utils/forgejo_client.py | 151 ++++++++++++++++++++++++++++++++++++++++ utils/gitlab_client.py | 143 ------------------------------------- 3 files changed, 175 insertions(+), 184 deletions(-) create mode 100644 utils/forgejo_client.py delete mode 100644 utils/gitlab_client.py diff --git a/serveur_flask.py b/serveur_flask.py index 7cb3435..0126fa4 100644 --- a/serveur_flask.py +++ b/serveur_flask.py @@ -12,17 +12,18 @@ app = Flask(__name__) PROJECT_PATH = "/home/discord/Bot/Kuby" WEBHOOK_SECRET = "Nois2" PROCESS_NAME = "kuby" +# Ton webhook de logs Discord DISCORD_LOG_URL = "https://discord.com/api/webhooks/1482148910888652910/IA9CcOWtjGswbuxMaOu_6uaclv5zojZo4ttxtV6RYyZzJ9gW7BF7xp_Zv3oPIjYEuh8o" -# Chemin absolu vers le binaire PM2 (évite l'erreur 127 command not found) +# Chemin vers PM2 PM2_BINARY = "/home/discord/.nvm/versions/node/v24.15.0/bin/pm2" -# Bot Local Proxy +# Bot Local Proxy (Relai vers le Cog de ton bot) BOT_LOCAL_URL = "http://127.0.0.1:5001/bot_event" def send_discord_log(status, message, color, author=None, commit_msg=None): embed = { - "title": f"🚀 Kuby V2 : {status}", + "title": f"🚀 Kuby V2 [Forgejo] : {status}", "description": message, "color": color, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -36,7 +37,7 @@ def send_discord_log(status, message, color, author=None, commit_msg=None): payload = {"embeds": [embed]} try: - requests.post(DISCORD_LOG_URL, json=payload, timeout=10) # ✅ Augmenté de 5s à 10s + requests.post(DISCORD_LOG_URL, json=payload, timeout=10) except Exception as e: print(f"[!] Erreur envoi Discord : {e}") @@ -58,29 +59,15 @@ def run_mep_logic(author, commit_msg, branch="main"): os.chdir(target_path) - # 1. GIT + # 1. GIT (Va maintenant pull depuis ton Forgejo auto-hébergé) subprocess.run(["git", "fetch", "origin"], check=True) subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True) - # 2. RSYNC (si on utilise le fallback de dépôt) + # 2. RSYNC (Fallback) if use_rsync: subprocess.run(["rsync", "-a", "--exclude=.git", "--exclude=venv", "--exclude=.venv", "--exclude=__pycache__", f"{target_path}/", f"{PROJECT_PATH}/"], check=True) - # ⚡ Migration automatique GitLab tracking - send_discord_log("MIGRATION", "Migration GitLab tracking en cours...", 16776960, author, commit_msg) - try: - subprocess.run( - ["python3", os.path.join(target_path, "scripts", "migrate_gitlab_tracking.py")], - cwd=target_path, - check=True, - capture_output=True, - text=True - ) - send_discord_log("MIGRATION", "✅ Migration GitLab tracking terminée.", 3066993, author, commit_msg) - except subprocess.CalledProcessError as e: - send_discord_log("ERREUR MIGRATION", f"❌ Migration GitLab échouée: {e.stderr}", 15158332, author, commit_msg) - - # 3. PM2 (Appelé directement via son chemin absolu pour corriger l'erreur 127) + # 3. PM2 Restart subprocess.run( [PM2_BINARY, "restart", PROCESS_NAME], check=True, @@ -89,26 +76,24 @@ def run_mep_logic(author, commit_msg, branch="main"): ) duration = (datetime.now() - start_time).total_seconds() - msg = f"✅ **Déploiement réussi en {duration:.2f}s**\nLe bot est à jour et redémarré." + msg = f"✅ **Déploiement réussi en {duration:.2f}s**\nLe bot est à jour sur Forgejo et redémarré." send_discord_log("VALIDÉ", msg, 3066993, author, commit_msg) except subprocess.CalledProcessError as e: - # Capture spécifique si une commande système (comme Git ou PM2) crash - error_stderr = e.stderr if e.stderr else "Pas de log d'erreur disponible (stderr vide)." + error_stderr = e.stderr if e.stderr else "Pas de log d'erreur disponible." error_msg = f"❌ **ERREUR de commande (Status {e.returncode})**\nCommande : `{ ' '.join(e.cmd) }`\n\n**Détails :**\n```\n{error_stderr}\n```" send_discord_log("ÉCHEC", error_msg, 15158332, author, commit_msg) except Exception as e: - # Capture globale pour le reste du script Python error_msg = f"❌ **ERREUR de déploiement**\n```python\n{str(e)}\n```" send_discord_log("ÉCHEC", error_msg, 15158332, author, commit_msg) @app.route('/deploy', methods=['POST']) def deploy(): - # Sécurité - if request.headers.get('X-Gitlab-Token') != WEBHOOK_SECRET: + # ⚡ MODIFICATION : Forgejo envoie son token secret dans 'X-Gitea-Token' + if request.headers.get('X-Gitea-Token') != WEBHOOK_SECRET: abort(403) - # Extraction des infos GitLab + # Extraction des infos (La structure des commits est identique à GitLab) data = request.json author = "Inconnu" commit_msg = "Pas de message" @@ -121,34 +106,32 @@ def deploy(): ref = data.get('ref', 'refs/heads/main') branch = ref.split('/')[-1] if ref else 'main' - # Threading pour répondre vite à GitLab + # Threading inchangé pour répondre instantanément à Forgejo threading.Thread(target=run_mep_logic, args=(author, commit_msg, branch)).start() return {"status": "accepted"}, 202 -@app.route('/gitlab_webhook', methods=['POST']) -def gitlab_webhook(): - # Protection par Token uniquement (anti-crawler géré par le secret) - received_token = request.headers.get('X-Gitlab-Token') +# Nouvelle route renommée pour la clarté, accepte aussi l'ancienne au cas où +@app.route('/forgejo_webhook', methods=['POST']) +@app.route('/gitlab_webhook', methods=['POST']) +def forgejo_webhook(): + # ⚡ MODIFICATION : Sécurité via l'en-tête Forgejo + received_token = request.headers.get('X-Gitea-Token') - # Log de debug pour voir ce que GitLab envoie vs ce qu'on attend - print(f"[DEBUG] Webhook reçu. Token attendu: '{WEBHOOK_SECRET}', Token reçu: '{received_token}'") + print(f"[DEBUG] Webhook d'événements reçu. Token reçu: '{received_token}'") if received_token != WEBHOOK_SECRET: - abort(403) # Changé en 403 pour différencier une route non trouvée d'un accès refusé + abort(403) - # Transférer la payload au bot Discord localement sur le port 5001 + # Relai direct de la payload de l'événement (tickets, commentaires) vers ton bot local au port 5001 payload = request.json try: - # On tente de relayer au bot discord. Timeout légèrement augmenté pour plus de stabilité. - # On ne passe plus de token interne pour simplifier comme demandé. requests.post(BOT_LOCAL_URL, json=payload, timeout=5) except Exception as e: print(f"[!] Erreur de relais vers le bot Discord : {e}") - # On renvoie 200 à GitLab quand même car GitLab n'a pas à savoir l'état interne return {"status": "received"}, 200 if __name__ == '__main__': - print(f"[*] Webhook opérationnel. En attente de push ou d'events...") + print(f"[*] Webhook Forgejo opérationnel sur le port 5000. Prêt pour les MEP !") app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/utils/forgejo_client.py b/utils/forgejo_client.py new file mode 100644 index 0000000..d66e61b --- /dev/null +++ b/utils/forgejo_client.py @@ -0,0 +1,151 @@ +import aiohttp +import os +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional, List + +# Logger interne mis à jour pour Forgejo +forgejo_internal_logger = logging.getLogger("ForgejoClient") +forgejo_internal_logger.setLevel(logging.INFO) + +class ForgejoClient: + def __init__(self): + self.token = os.getenv("FORGEJO_TOKEN") + self.owner = os.getenv("FORGEJO_OWNER") # Exemple: "Gameur" + self.repo = os.getenv("FORGEJO_REPO") # Exemple: "kuby" + self.assignee = os.getenv("FORGEJO_ASSIGNEE") # Nom d'utilisateur (optionnel) + + # URL de base automatique via ton Tailscale + self.base_url = os.getenv("FORGEJO_URL", "https://omegakubeserv.tail951d2f.ts.net").rstrip("/") + + # Construction de l'URL de l'API v1 standard de Forgejo/Gitea + if "api/v1" not in self.base_url: + self.api_url = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues" + else: + self.api_url = f"{self.base_url}/repos/{self.owner}/{self.repo}/issues" + + async def create_issue(self, title: str, description: str, labels: Optional[List[str]] = None) -> Optional[dict]: + """ + Crée un ticket sur Forgejo. + """ + if not self.token or not self.owner or not self.repo: + if not hasattr(self, '_config_warned'): + print("⚠️ L'intégration Forgejo n'est pas configurée.") + self._config_warned = True + return None + + headers = { + "Authorization": f"token {self.token}", + "Content-Type": "application/json" + } + + # Payload au format Forgejo (JSON) + payload = { + "title": title, + "body": description, # 'description' devient 'body' + "labels": labels if labels else [] # Devient une vraie liste [] + } + + if self.assignee: + payload["assignees"] = [self.assignee] # Liste de pseudos strings + + async with aiohttp.ClientSession() as session: + try: + # Forgejo attend du JSON strict (json=payload) + async with session.post(self.api_url, headers=headers, json=payload) as response: + if response.status == 201: + result = await response.json() + # Forgejo renvoie l'URL dans 'html_url' au lieu de 'web_url' + forgejo_internal_logger.info(f"✅ Forgejo issue créée : {result.get('html_url')}") + return result + else: + error_text = await response.text() + forgejo_internal_logger.error(f"❌ Échec de la création : {response.status} - {error_text}") + return None + except Exception as e: + forgejo_internal_logger.error(f"❌ Erreur de connexion à l'API Forgejo : {e}") + return None + + async def find_issue_by_title(self, title: str, state: str = "open") -> Optional[dict]: + """ + Recherche un ticket par son titre exact et son état (open/closed). + """ + if not self.token or not self.owner or not self.repo: + return None + + headers = {"Authorization": f"token {self.token}"} + # GitLab utilise 'opened', Forgejo utilise 'open' + forgejo_state = "open" if state == "opened" else state + + params = { + "q": title, + "state": forgejo_state + } + + async with aiohttp.ClientSession() as session: + try: + async with session.get(self.api_url, headers=headers, params=params) as response: + if response.status == 200: + issues = await response.json() + for issue in issues: + if issue.get("title") == title: + return issue + return None + else: + return None + except Exception as e: + forgejo_internal_logger.error(f"❌ Erreur lors de la recherche Forgejo : {e}") + return None + + async def get_closed_issues(self) -> List[dict]: + """ + Récupère les tickets récemment fermés (depuis 24h). + """ + if not self.token or not self.owner or not self.repo: + return [] + + headers = {"Authorization": f"token {self.token}"} + # Forgejo utilise 'since' au format ISO pour filtrer par date + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + + params = { + "state": "closed", + "since": yesterday + } + + async with aiohttp.ClientSession() as session: + try: + async with session.get(self.api_url, headers=headers, params=params) as response: + if response.status == 200: + return await response.json() + else: + error_text = await response.text() + forgejo_internal_logger.error(f"❌ Erreur récupération tickets fermés : {response.status} - {error_text}") + return [] + except Exception as e: + forgejo_internal_logger.error(f"❌ Erreur connexion API Forgejo : {e}") + return [] + + async def get_issue(self, issue_number: int) -> Optional[dict]: + """ + Récupère un ticket spécifique via son numéro (ID interne). + """ + if not self.token or not self.owner or not self.repo: + return None + + headers = {"Authorization": f"token {self.token}"} + url = f"{self.api_url}/{issue_number}" + + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers=headers) as response: + if response.status == 200: + return await response.json() + else: + return None + except Exception as e: + forgejo_internal_logger.error(f"❌ Erreur connexion API Forgejo : {e}") + return None + +# Instance unique +gitlab_client = ForgejoClient() # Nom conservé pour éviter de réécrire les imports du bot \ No newline at end of file diff --git a/utils/gitlab_client.py b/utils/gitlab_client.py deleted file mode 100644 index 40d7993..0000000 --- a/utils/gitlab_client.py +++ /dev/null @@ -1,143 +0,0 @@ -import aiohttp -import os -import logging -from datetime import datetime, timedelta -from typing import Optional, List - -# Use a separate logger to avoid infinite loops if GitLab reporting fails -gitlab_internal_logger = logging.getLogger("GitLabClient") -gitlab_internal_logger.setLevel(logging.INFO) -# We don't add handlers here, it will just log to console through hierarchy or be silent if needed - -class GitLabClient: - def __init__(self): - self.token = os.getenv("GITLAB_TOKEN") - self.project_id = os.getenv("GITLAB_PROJECT_ID") - self.assignee_id = os.getenv("GITLAB_ASSIGNEE_ID") - self.base_url = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/") - # Ensure base_url only contains the schema and domain for the API endpoint - if "api/v4" not in self.base_url: - parts = self.base_url.split("/") - api_base = "/".join(parts[:3]) # Gets https://gitlab.com - self.api_url = f"{api_base}/api/v4/projects/{self.project_id}/issues" - else: - self.api_url = f"{self.base_url}/projects/{self.project_id}/issues" - - async def create_issue(self, title: str, description: str, labels: Optional[List[str]] = None) -> Optional[dict]: - """ - Creates an issue on GitLab. - """ - if not self.token or not self.project_id: - # Use a print or a lower level log to avoid triggering the GitLab logger itself - # which could cause an infinite loop if this were an ERROR log. - if not hasattr(self, '_config_warned'): - print("⚠️ GitLab integration is not configured. GitLab issues will not be created.") - self._config_warned = True - return None - - headers = { - "PRIVATE-TOKEN": self.token - } - - data = { - "title": title, - "description": description, - "labels": ",".join(labels) if labels else "" - } - - if self.assignee_id: - data["assignee_ids"] = [self.assignee_id] - - async with aiohttp.ClientSession() as session: - try: - async with session.post(self.api_url, headers=headers, data=data) as response: - if response.status == 201: - result = await response.json() - gitlab_internal_logger.info(f"✅ GitLab issue created: {result.get('web_url')}") - return result - else: - error_text = await response.text() - gitlab_internal_logger.error(f"❌ Failed to create GitLab issue: {response.status} - {error_text}") - return None - except Exception as e: - gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}") - return None - - async def find_issue_by_title(self, title: str, state: str = "opened") -> Optional[dict]: - """ - Searches for an issue by its exact title and state. - """ - if not self.token or not self.project_id: - return None - - headers = {"PRIVATE-TOKEN": self.token} - params = { - "search": title, - "state": state - } - - async with aiohttp.ClientSession() as session: - try: - # search param is fuzzy on GitLab, so we must double-check for exact match - async with session.get(self.api_url, headers=headers, params=params) as response: - if response.status == 200: - issues = await response.json() - for issue in issues: - if issue.get("title") == title: - return issue - return None - else: - return None - except Exception as e: - gitlab_internal_logger.error(f"❌ Error searching GitLab issues: {e}") - return None - - async def get_closed_issues(self) -> List[dict]: - """ - Fetches recently closed issues. - """ - if not self.token or not self.project_id: - return [] - - headers = {"PRIVATE-TOKEN": self.token} - params = { - "state": "closed", - "updated_after": (datetime.now() - timedelta(days=1)).isoformat() - } - - async with aiohttp.ClientSession() as session: - try: - async with session.get(self.api_url, headers=headers, params=params) as response: - if response.status == 200: - return await response.json() - else: - error_text = await response.text() - gitlab_internal_logger.error(f"❌ Failed to fetch closed issues: {response.status} - {error_text}") - return [] - except Exception as e: - gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}") - return [] - - async def get_issue(self, issue_iid: int) -> Optional[dict]: - """ - Fetches a specific issue by its internal ID (IID). - """ - if not self.token or not self.project_id: - return None - - headers = {"PRIVATE-TOKEN": self.token} - url = f"{self.base_url}/api/v4/projects/{self.project_id}/issues/{issue_iid}" - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, headers=headers) as response: - if response.status == 200: - return await response.json() - else: - return None - except Exception as e: - gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}") - return None - -# Instance unique -gitlab_client = GitLabClient() From d8ef20ba8cdf1b5bba5e50d87eeeadd609f3376b Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 11 Jul 2026 21:42:45 +0200 Subject: [PATCH 6/6] =?UTF-8?q?Mise=20=C3=A0=20jour:=20migration=20de=20gi?= =?UTF-8?q?tlab=20-->=20Forgejo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/gitlab_issues.json | 5 ++++ serveur_flask.py | 2 +- src/logger.py | 2 +- utils/forgejo_client.py | 54 +++++++++++++++++++++++++----------- utils/gitlab_client.py | 16 +++++++++++ 5 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 utils/gitlab_client.py diff --git a/commandes/gitlab_issues.json b/commandes/gitlab_issues.json index 8174dd2..4003089 100644 --- a/commandes/gitlab_issues.json +++ b/commandes/gitlab_issues.json @@ -38,5 +38,10 @@ "requester_id": 971446412690722826, "last_note_id": 0, "type": "legacy_report" + }, + "None": { + "requester_id": 971446412690722826, + "last_note_id": 0, + "type": "manual-report" } } \ No newline at end of file diff --git a/serveur_flask.py b/serveur_flask.py index 0126fa4..190e4c8 100644 --- a/serveur_flask.py +++ b/serveur_flask.py @@ -12,7 +12,7 @@ app = Flask(__name__) PROJECT_PATH = "/home/discord/Bot/Kuby" WEBHOOK_SECRET = "Nois2" PROCESS_NAME = "kuby" -# Ton webhook de logs Discord +# webhook de logs Discord DISCORD_LOG_URL = "https://discord.com/api/webhooks/1482148910888652910/IA9CcOWtjGswbuxMaOu_6uaclv5zojZo4ttxtV6RYyZzJ9gW7BF7xp_Zv3oPIjYEuh8o" # Chemin vers PM2 diff --git a/src/logger.py b/src/logger.py index ac149d8..8c4aaee 100755 --- a/src/logger.py +++ b/src/logger.py @@ -52,7 +52,7 @@ class KubyLogger: # GitLab handler for automated error reporting try: - from utils.gitlab_client import gitlab_client + from utils.forgejo_client import gitlab_client class GitLabHandler(logging.Handler): def __init__(self, client): super().__init__() diff --git a/utils/forgejo_client.py b/utils/forgejo_client.py index d66e61b..1768b73 100644 --- a/utils/forgejo_client.py +++ b/utils/forgejo_client.py @@ -8,15 +8,31 @@ from typing import Optional, List forgejo_internal_logger = logging.getLogger("ForgejoClient") forgejo_internal_logger.setLevel(logging.INFO) +# Alias de compatibilité pour les anciens imports du bot +gitlab_internal_logger = forgejo_internal_logger + class ForgejoClient: + # MAP REEL DES LABELS (Pêchés directement depuis API Forgejo) + LABEL_MAPPING = { + "bug": 10, + "suggestion": 11, # Redirige "suggestion" vers "enhancement" + "enhancement": 11, # ID de ton étiquette bleue + "manual-report": 15, + "manual-suggestion": 16 + } + def __init__(self): self.token = os.getenv("FORGEJO_TOKEN") self.owner = os.getenv("FORGEJO_OWNER") # Exemple: "Gameur" self.repo = os.getenv("FORGEJO_REPO") # Exemple: "kuby" - self.assignee = os.getenv("FORGEJO_ASSIGNEE") # Nom d'utilisateur (optionnel) + self.assignee = os.getenv("FORGEJO_ASSIGNEE") # Pseudo string (optionnel) - # URL de base automatique via ton Tailscale - self.base_url = os.getenv("FORGEJO_URL", "https://omegakubeserv.tail951d2f.ts.net").rstrip("/") + # URL de base automatique via ton Tailscale. + # FIX DE SECURITE : On force le protocole HTTP brut pour éviter l'erreur "ssl:default" sur le port 80 + configured_url = os.getenv("FORGEJO_URL", "http://omegakubeserv.tail951d2f.ts.net").strip().rstrip("/") + if configured_url.startswith("https://"): + configured_url = configured_url.replace("https://", "http://", 1) + self.base_url = configured_url # Construction de l'URL de l'API v1 standard de Forgejo/Gitea if "api/v1" not in self.base_url: @@ -26,11 +42,11 @@ class ForgejoClient: async def create_issue(self, title: str, description: str, labels: Optional[List[str]] = None) -> Optional[dict]: """ - Crée un ticket sur Forgejo. + Crée un ticket sur Forgejo avec les bons IDs numériques de labels. """ if not self.token or not self.owner or not self.repo: if not hasattr(self, '_config_warned'): - print("⚠️ L'intégration Forgejo n'est pas configurée.") + forgejo_internal_logger.warning("⚠️ L'intégration Forgejo n'est pas configurée (token/owner/repo manquants).") self._config_warned = True return None @@ -39,23 +55,30 @@ class ForgejoClient: "Content-Type": "application/json" } - # Payload au format Forgejo (JSON) + # CONVERSION DES LABELS : On transforme les chaînes ("bug") en entiers (10) attendus par l'API Go + forgejo_label_ids = [] + if labels: + for label in labels: + label_id = self.LABEL_MAPPING.get(label.lower()) + if label_id: + forgejo_label_ids.append(label_id) + + # Payload au format Forgejo strict payload = { "title": title, - "body": description, # 'description' devient 'body' - "labels": labels if labels else [] # Devient une vraie liste [] + "body": description, + "labels": forgejo_label_ids # Reçoit désormais un tableau d'int64 (ex: [10]) } if self.assignee: - payload["assignees"] = [self.assignee] # Liste de pseudos strings + payload["assignees"] = [self.assignee] - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout) as session: try: - # Forgejo attend du JSON strict (json=payload) async with session.post(self.api_url, headers=headers, json=payload) as response: if response.status == 201: result = await response.json() - # Forgejo renvoie l'URL dans 'html_url' au lieu de 'web_url' forgejo_internal_logger.info(f"✅ Forgejo issue créée : {result.get('html_url')}") return result else: @@ -74,7 +97,6 @@ class ForgejoClient: return None headers = {"Authorization": f"token {self.token}"} - # GitLab utilise 'opened', Forgejo utilise 'open' forgejo_state = "open" if state == "opened" else state params = { @@ -105,7 +127,6 @@ class ForgejoClient: return [] headers = {"Authorization": f"token {self.token}"} - # Forgejo utilise 'since' au format ISO pour filtrer par date yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() params = { @@ -147,5 +168,6 @@ class ForgejoClient: forgejo_internal_logger.error(f"❌ Erreur connexion API Forgejo : {e}") return None -# Instance unique -gitlab_client = ForgejoClient() # Nom conservé pour éviter de réécrire les imports du bot \ No newline at end of file +# --- SÉCURITÉ ET ALIAS DE COMPATIBILITÉ --- +GitLabClient = ForgejoClient +gitlab_client = ForgejoClient() # Singleton conservé sous l'ancien nom pour le reste du bot \ No newline at end of file diff --git a/utils/gitlab_client.py b/utils/gitlab_client.py new file mode 100644 index 0000000..8fc9ecd --- /dev/null +++ b/utils/gitlab_client.py @@ -0,0 +1,16 @@ +"""Compatibilité rétroactive pour les anciens imports du bot. + +Certaines commandes ou anciens modules peuvent encore importer +`utils.gitlab_client`. Ce fichier sert de pont vers le client Forgejo +actuel sans casser le chargement du bot. +""" + +from utils.forgejo_client import ForgejoClient, forgejo_internal_logger, gitlab_internal_logger + +# Nom conservé pour ne pas casser les imports existants +# (les anciens cogs utilisent souvent `gitlab_client`) +gitlab_client = ForgejoClient() + +# Exposition explicite du logger pour compatibilité +# pylint: disable=invalid-name +gitlab_internal_logger = gitlab_internal_logger