Compare commits

...
Sign in to create a new pull request.

4 commits
main ... dev

31 changed files with 1146 additions and 411 deletions

15
TODO.md Normal file
View file

@ -0,0 +1,15 @@
# TODO - Désactivation fonctionnalités premium (SKU) & correction accès /ticket
## É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)

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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 nas 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 nas pas la permission.", ephemeral=True)

View file

@ -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)

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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")
@ -191,11 +192,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}")
@ -229,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:

View file

@ -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()

View file

@ -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,

View file

@ -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

View file

@ -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é.

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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"""

View file

@ -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)

View file

@ -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."""

View file

@ -38,9 +38,13 @@ 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...")
# 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 +157,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 +167,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 +458,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 +581,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 +596,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 +766,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
@ -1005,9 +1054,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:
@ -1222,9 +1275,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:
@ -2050,6 +2107,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 +2358,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 +2452,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 +2469,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 +2494,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 +2509,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 +2546,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 +2648,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 +2813,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 +3338,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_<catid>_<suffix>
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 +3352,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)
@ -3341,7 +3495,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
@ -3417,9 +3580,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)
@ -3568,33 +3735,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 lindexation.
# Ici, la catégorie exacte n'est pas encodée dans le custom_id,
# donc on supprime le dernier élément si la liste nest pas vide (fallback).
# (Meilleur: corriger lencodage 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)
@ -3803,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"""
@ -3826,11 +3978,16 @@ 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}")
@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)

View file

@ -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)
)

View file

@ -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):

View file

@ -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)

View file

@ -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

210
test_paypal_logic.py Normal file
View file

@ -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()

200
test_premium.py Normal file
View file

@ -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()

View file

@ -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)

205
utils/premium.py Normal file
View file

@ -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)

5
whitelist_parteners.json Normal file
View file

@ -0,0 +1,5 @@
{
"partners": [
1369669999345537145
]
}

5
whitelist_partners.json Normal file
View file

@ -0,0 +1,5 @@
{
"partners": [
1369669999345537145
]
}