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