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: except ImportError:
from backports.zoneinfo import ZoneInfo from backports.zoneinfo import ZoneInfo
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from utils.premium import check_premium_tier
PARIS_TZ = ZoneInfo("Europe/Paris") PARIS_TZ = ZoneInfo("Europe/Paris")
DATE_FORMAT = "%d/%m/%Y %H:%M" DATE_FORMAT = "%d/%m/%Y %H:%M"
@ -259,12 +260,14 @@ class Absence(commands.Cog):
self.check_absences.start() self.check_absences.start()
@commands.slash_command(name="absence", description="Déclarer une absence staff") @commands.slash_command(name="absence", description="Déclarer une absence staff")
@check_premium_tier()
async def absence(self, interaction: disnake.ApplicationCommandInteraction, async def absence(self, interaction: disnake.ApplicationCommandInteraction,
superieur: Optional[disnake.Member] = commands.Param(None, description="Le supérieur hiérarchique à prévenir")): superieur: Optional[disnake.Member] = commands.Param(None, description="Le supérieur hiérarchique à prévenir")):
"""Déclare une absence via un formulaire""" """Déclare une absence via un formulaire"""
await interaction.response.send_modal(AbsenceModal(superieur)) await interaction.response.send_modal(AbsenceModal(superieur))
@commands.slash_command(name="absence_config", description="Configurer le système d'absence (Admin)") @commands.slash_command(name="absence_config", description="Configurer le système d'absence (Admin)")
@check_premium_tier()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def absence_config(self, interaction: disnake.ApplicationCommandInteraction): async def absence_config(self, interaction: disnake.ApplicationCommandInteraction):
"""Ouvre le panneau de configuration des absences""" """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}") print(f"Erreur lors du barrage du message d'absence: {e}")
@commands.slash_command(name="fin_absence", description="Terminer son absence staff") @commands.slash_command(name="fin_absence", description="Terminer son absence staff")
@check_premium_tier()
async def fin_absence(self, interaction: disnake.ApplicationCommandInteraction): async def fin_absence(self, interaction: disnake.ApplicationCommandInteraction):
"""Met fin à l'absence en cours""" """Met fin à l'absence en cours"""
all_abs = await load_absences() 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) 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") @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): async def liste_absences(self, interaction: disnake.ApplicationCommandInteraction):
"""Affiche les absences en cours sur le serveur""" """Affiche les absences en cours sur le serveur"""
all_abs = await load_absences() all_abs = await load_absences()

View file

@ -9,6 +9,7 @@ from typing import Dict, Any, List
import disnake import disnake
from disnake.ext import commands from disnake.ext import commands
from utils.premium import check_premium_tier
BACKUPS_ROOT = "data/security_backups" # dossier de sortie des sauvegardes BACKUPS_ROOT = "data/security_backups" # dossier de sortie des sauvegardes
@ -64,6 +65,7 @@ class Backup(commands.Cog):
self.bot = bot self.bot = bot
@commands.slash_command(name="backup", description="Outils de sauvegarde du serveur") @commands.slash_command(name="backup", description="Outils de sauvegarde du serveur")
@check_premium_tier()
async def backup_group(self, interaction: disnake.ApplicationCommandInteraction): async def backup_group(self, interaction: disnake.ApplicationCommandInteraction):
pass pass

View file

@ -7,6 +7,7 @@ import shutil
from typing import List from typing import List
import disnake import disnake
from disnake.ext import commands from disnake.ext import commands
from utils.premium import check_premium_tier
BACKUPS_ROOT = "data/security_backups" BACKUPS_ROOT = "data/security_backups"
@ -133,6 +134,7 @@ class BackupRestore(commands.Cog):
await channel.send(msg["content"]) await channel.send(msg["content"])
@commands.slash_command(name="granulaire", description="Restaure un élément précis d'une sauvegarde") @commands.slash_command(name="granulaire", description="Restaure un élément précis d'une sauvegarde")
@check_premium_tier()
async def restore_granulaire( async def restore_granulaire(
self, self,
interaction: disnake.ApplicationCommandInteraction, interaction: disnake.ApplicationCommandInteraction,

View file

@ -4,6 +4,7 @@ import json
import os import os
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from utils.premium import check_premium_tier
BLACKLIST_FILE = "data/blacklist.json" BLACKLIST_FILE = "data/blacklist.json"
AGENTS_FILE = "data/agents.json" AGENTS_FILE = "data/agents.json"
@ -57,6 +58,7 @@ class Blacklist(commands.Cog):
return str(user_id) in agents["agents"] return str(user_id) in agents["agents"]
@commands.slash_command(name="blacklist", description="Blacklist un utilisateur") @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"): async def blacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User, reason: str = "Aucune raison fournie"):
if not self.is_agent(interaction.user.id): if not self.is_agent(interaction.user.id):
return await interaction.response.send_message("⛔ Tu nas pas la permission.", ephemeral=True) 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") @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): async def unblacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User):
if not self.is_agent(interaction.user.id): if not self.is_agent(interaction.user.id):
return await interaction.response.send_message("⛔ Tu nas pas la permission.", ephemeral=True) return await interaction.response.send_message("⛔ Tu nas pas la permission.", ephemeral=True)

View file

@ -7,6 +7,7 @@ import json
import os import os
import asyncio import asyncio
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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) return web.json_response({"status": "error", "message": str(e)}, status=500)
@commands.slash_command(name="signaler_bug", description="Signaler un bug aux développeurs") @commands.slash_command(name="signaler_bug", description="Signaler un bug aux développeurs")
@check_premium_tier()
async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction): async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction):
modal = BugReportModal(self) modal = BugReportModal(self)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
@commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité") @commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité")
@check_premium_tier()
async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction): async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction):
modal = FeatureSuggestionModal(self) modal = FeatureSuggestionModal(self)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)

View file

@ -6,6 +6,7 @@ from typing import List, Optional, Dict
import datetime import datetime
import asyncio import asyncio
import logging import logging
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
@ -61,6 +62,7 @@ class Convocation(commands.Cog):
save_json(CONV_BACKUPS_FILE, self.backups) save_json(CONV_BACKUPS_FILE, self.backups)
@commands.slash_command(name="convocation", description="Gère les convocations de membres") @commands.slash_command(name="convocation", description="Gère les convocations de membres")
@check_premium_tier()
@commands.has_permissions(manage_roles=True) @commands.has_permissions(manage_roles=True)
async def convocation_group(self, interaction: disnake.ApplicationCommandInteraction): async def convocation_group(self, interaction: disnake.ApplicationCommandInteraction):
pass pass

View file

@ -8,83 +8,64 @@ import aiohttp
import disnake import disnake
from disnake.ext import commands, tasks from disnake.ext import commands, tasks
# Environment variables for GitLab access # Configuration des variables d'environnement pour Forgejo
GITLAB_TOKEN = os.getenv("GITLAB_TOKEN") FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN")
GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID") # numeric ID or URL-encoded path 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: if not FORGEJO_TOKEN or not FORGEJO_OWNER or not FORGEJO_REPO:
raise RuntimeError("GitLab token and project ID must be set in environment variables GITLAB_TOKEN and GITLAB_PROJECT_ID") raise RuntimeError("Forgejo variables (FORGEJO_TOKEN, FORGEJO_OWNER, FORGEJO_REPO) must be set.")
# Files to persist issue tracking information # Fichiers de persistance mis à jour pour Forgejo
ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "gitlab_issues.json") ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "forgejo_issues.json")
LEGACY_REPORTS_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "gitlab_reports.json")
def load_issue_tracking() -> Dict[str, Any]: def load_issue_tracking() -> Dict[str, Any]:
"""Charge le tracking et fusionne automatiquement avec gitlab_reports.json (migration)"""
tracking = {} tracking = {}
# Charge le fichier principal
if os.path.exists(ISSUE_TRACK_FILE): if os.path.exists(ISSUE_TRACK_FILE):
try: try:
with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f: with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f:
tracking = json.load(f) tracking = json.load(f)
except Exception as e: except Exception as e:
print(f"⚠️ Erreur chargement gitlab_issues.json: {e}") print(f"⚠️ Erreur chargement forgejo_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}")
return tracking return tracking
def save_issue_tracking(data: Dict[str, Any]) -> None: def save_issue_tracking(data: Dict[str, Any]) -> None:
with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f: with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2) json.dump(data, f, ensure_ascii=False, indent=2)
class GitLabIntegration(commands.Cog): class ForgejoIntegration(commands.Cog):
"""Cog providing slash commands to create GitLab issues (feature request / bug) and notify requesters when comments are added.""" """Cog fournissant des commandes slash pour créer des tickets sur Forgejo et notifier les utilisateurs."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = 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.issue_data: Dict[str, Any] = load_issue_tracking()
self.check_new_notes.start() self.check_new_comments.start()
def cog_unload(self): 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]]: async def _create_forgejo_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.""" """Crée un ticket sur Forgejo et le track automatiquement."""
url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues" url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues"
headers = { headers = {
"PRIVATE-TOKEN": GITLAB_TOKEN, "Authorization": f"token {FORGEJO_TOKEN}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
payload = { payload = {
"title": title, "title": title,
"description": description, "body": description, # GitLab utilisait 'description', Forgejo utilise 'body'
"labels": ",".join(labels), "labels": labels, # Forgejo accepte directement une liste ['bug', 'bot']
} }
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload) as resp: async with session.post(url, headers=headers, json=payload) as resp:
resp.raise_for_status() resp.raise_for_status()
result = await resp.json() result = await resp.json()
# Track automatiquement la nouvelle issue
if requester_id: if requester_id:
issue_iid = str(result["iid"]) issue_number = str(result["number"]) # 'iid' devient 'number'
self.issue_data[issue_iid] = { self.issue_data[issue_number] = {
"requester_id": requester_id, "requester_id": requester_id,
"last_note_id": 0, "last_note_id": 0,
"type": "discord-command" "type": "discord-command"
@ -92,186 +73,140 @@ class GitLabIntegration(commands.Cog):
save_issue_tracking(self.issue_data) save_issue_tracking(self.issue_data)
return result return result
except Exception as e: except Exception as e:
print(f"❌ Erreur création issue GitLab: {e}") print(f"❌ Erreur création issue Forgejo: {e}")
return None return None
async def _get_issue_notes(self, issue_iid: int) -> List[Dict[str, Any]]: async def _get_issue_comments(self, issue_number: int) -> List[Dict[str, Any]]:
"""Fetch all notes (comments) for a given issue.""" """Récupère tous les commentaires d'un ticket spécifique."""
url = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/issues/{issue_iid}/notes" url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue_number}/comments"
headers = {"PRIVATE-TOKEN": GITLAB_TOKEN} headers = {"Authorization": f"token {FORGEJO_TOKEN}"}
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp: async with session.get(url, headers=headers) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def _notify_requester(self, requester_id: int, issue_iid: int, note: Dict[str, Any]): async def _notify_requester(self, requester_id: int, issue_number: int, comment: Dict[str, Any]):
"""Send a private DM to the requester informing them of a new comment.""" """Envoie un DM privé à l'utilisateur lors d'un nouveau commentaire."""
user = await self.bot.fetch_user(requester_id) user = await self.bot.fetch_user(requester_id)
author = note.get("author", {}).get("name", "Someone") author = comment.get("user", {}).get("username", "Quelqu'un") # 'author.name' devient 'user.username'
body = note.get("body", "") body = comment.get("body", "")
embed = disnake.Embed( 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, description=body,
color=disnake.Color.blurple(), color=disnake.Color.brand_green(),
timestamp=datetime.fromisoformat(note["created_at"]).replace(tzinfo=timezone.utc), timestamp=datetime.fromisoformat(comment["created_at"].replace("Z", "+00:00")),
) )
embed.set_footer(text=f"Commentaire de {author}") embed.set_footer(text=f"Commentaire de {author}")
try: try:
await user.send(embed=embed) await user.send(embed=embed)
except disnake.HTTPException: except disnake.HTTPException:
# The user may have DMs disabled; ignore silently.
pass pass
@tasks.loop(minutes=1) @tasks.loop(minutes=1)
async def check_new_notes(self): async def check_new_comments(self):
"""Vérifie TOUS les nouveaux commentaires (pas seulement le dernier) et notifie.""" """Boucle de vérification des commentaires Forgejo."""
for issue_iid_str, info in list(self.issue_data.items()): for issue_num_str, info in list(self.issue_data.items()):
issue_iid = int(issue_iid_str) issue_number = int(issue_num_str)
last_known_id = info.get("last_note_id", 0) last_known_id = info.get("last_note_id", 0)
requester_id = info.get("requester_id") requester_id = info.get("requester_id")
try: try:
notes = await self._get_issue_notes(issue_iid) comments = await self._get_issue_comments(issue_number)
if not notes: if not comments:
continue continue
# ⚡ NOUVEAU: Notifie TOUS les commentaires manquants, pas seulement le dernier missing_comments = [c for c in comments if c["id"] > last_known_id]
missing_notes = [n for n in notes if n["id"] > last_known_id] if missing_comments:
if missing_notes: for comment in missing_comments:
for note in missing_notes: await self._notify_requester(requester_id, issue_number, comment)
await self._notify_requester(requester_id, issue_iid, note)
# Met à jour avec le dernier ID vu new_last_id = max(c["id"] for c in comments)
new_last_id = max(n["id"] for n in notes) self.issue_data[issue_num_str]["last_note_id"] = new_last_id
self.issue_data[issue_iid_str]["last_note_id"] = new_last_id
except Exception as e: except Exception as e:
print(f"⚠️ Erreur vérification issue #{issue_iid}: {e}") print(f"⚠️ Erreur vérification issue #{issue_number}: {e}")
continue continue
save_issue_tracking(self.issue_data) 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): async def recuperer_suivi(self, inter: disnake.ApplicationCommandInteraction):
"""Reconstruit gitlab_issues.json avec les derniers commentaires."""
await inter.response.defer(ephemeral=True) await inter.response.defer(ephemeral=True)
updated_count = 0 updated_count = 0
for issue_iid_str, info in list(self.issue_data.items()): for issue_num_str, info in list(self.issue_data.items()):
issue_iid = int(issue_iid_str) issue_number = int(issue_num_str)
try: try:
notes = await self._get_issue_notes(issue_iid) comments = await self._get_issue_comments(issue_number)
if notes: if comments:
latest_id = max(n["id"] for n in notes) latest_id = max(c["id"] for c in comments)
if latest_id > info.get("last_note_id", 0): 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 updated_count += 1
except Exception as e: 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 continue
save_issue_tracking(self.issue_data) save_issue_tracking(self.issue_data)
await inter.edit_original_message( await inter.edit_original_message(
content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues. " content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues."
f"Fichier `{ISSUE_TRACK_FILE}` mis à jour."
) )
# ---------- Slash commands ----------
@commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet") @commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet")
async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction): async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction):
await inter.response.send_modal(modal=FeatureModal(self)) 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): async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction):
await inter.response.send_modal(modal=BugModal(self)) await inter.response.send_modal(modal=BugModal(self))
# ---------- Modals ----------
# ---------- Modals Acteurs ----------
class FeatureModal(disnake.Modal): class FeatureModal(disnake.Modal):
def __init__(self, cog: GitLabIntegration): def __init__(self, cog: ForgejoIntegration):
components = [ components = [
disnake.ui.TextInput( disnake.ui.TextInput(label="Titre de la fonctionnalité", custom_id="title", max_length=100, required=True),
label="Titre de la fonctionnalité", disnake.ui.TextInput(label="Description détaillée", custom_id="description", style=disnake.TextInputStyle.paragraph, max_length=2000, required=True),
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,
),
] ]
super().__init__( super().__init__(title="Suggestion de fonctionnalité", custom_id="feature_modal", components=components)
title="Suggestion de fonctionnalité",
custom_id="feature_modal",
components=components,
)
self.cog = cog self.cog = cog
async def callback(self, inter: disnake.ModalInteraction): async def callback(self, inter: disnake.ModalInteraction):
title = inter.text_values["title"] title = inter.text_values["title"]
description = inter.text_values["description"] description = inter.text_values["description"]
await inter.response.defer(ephemeral=True) await inter.response.defer(ephemeral=True)
try:
issue = await self.cog._create_gitlab_issue( issue = await self.cog._create_forgejo_issue(
title=title, title=title, description=description, labels=["feature", "discord-bot"], requester_id=inter.author.id
description=description, )
labels=["feature", "discord-bot"], if issue:
requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto 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})")
if issue: else:
await inter.edit_original_message( await inter.edit_original_message(content="❌ Erreur lors de la création sur Forgejo.")
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}")
class BugModal(disnake.Modal): class BugModal(disnake.Modal):
def __init__(self, cog: GitLabIntegration): def __init__(self, cog: ForgejoIntegration):
components = [ components = [
disnake.ui.TextInput( disnake.ui.TextInput(label="Titre du bug", custom_id="title", max_length=100, required=True),
label="Titre du bug", disnake.ui.TextInput(label="Description et étapes", style=disnake.TextInputStyle.paragraph, custom_id="description", max_length=2000, required=True),
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,
),
] ]
super().__init__( super().__init__(title="Signalement de bug", custom_id="bug_modal", components=components)
title="Signalement de bug",
custom_id="bug_modal",
components=components,
)
self.cog = cog self.cog = cog
async def callback(self, inter: disnake.ModalInteraction): async def callback(self, inter: disnake.ModalInteraction):
title = inter.text_values["title"] title = inter.text_values["title"]
description = inter.text_values["description"] description = inter.text_values["description"]
await inter.response.defer(ephemeral=True) await inter.response.defer(ephemeral=True)
try:
issue = await self.cog._create_gitlab_issue( issue = await self.cog._create_forgejo_issue(
title=title, title=title, description=description, labels=["bug", "discord-bot"], requester_id=inter.author.id
description=description, )
labels=["bug", "discord-bot"], if issue:
requester_id=inter.author.id # ⚡ NOUVEAU: Passe le requester_id pour tracking auto 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})")
if issue: else:
await inter.edit_original_message( await inter.edit_original_message(content="❌ Erreur lors du signalement sur Forgejo.")
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}")
def setup(bot: commands.Bot): 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 import logging
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timezone from datetime import datetime, timezone
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
@ -285,6 +286,7 @@ class Goodbye(commands.Cog):
return os.path.basename(banner_path) 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") @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) @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): 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) # ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)

View file

@ -5,6 +5,7 @@ import os
import logging import logging
from typing import Dict, Optional from typing import Dict, Optional
import asyncio import asyncio
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
@ -191,11 +192,16 @@ class Invites(commands.Cog):
current_present = 0 current_present = 0
for mid in invited_ids: 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 current_present += 1
else: else:
kuby_logger.debug(f"[Invites] Membre invité {mid} non trouvé dans le serveur {guild_id}") 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 return len(invited_ids), current_present
except Exception as e: except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la récupération des statistiques d'invitations pour {user_id}: {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}") 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") @commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations")
@check_premium_tier()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel): async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel):
try: try:

View file

@ -5,6 +5,7 @@ import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re import re
from src.logger import kuby_logger from src.logger import kuby_logger
from utils.premium import check_premium_tier
class ModReasonModal(disnake.ui.Modal): class ModReasonModal(disnake.ui.Modal):
def __init__(self, cog, action_type, member): def __init__(self, cog, action_type, member):
@ -254,6 +255,7 @@ class Moderation(commands.Cog):
await interaction.response.send_message(components=components, ephemeral=True) await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="modpanel", description="Ouvre le panel de modération") @commands.slash_command(name="modpanel", description="Ouvre le panel de modération")
@check_premium_tier()
@commands.has_permissions(moderate_members=True) @commands.has_permissions(moderate_members=True)
async def modpanel(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member): 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: 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) await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre") @commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre")
@check_premium_tier()
@commands.has_permissions(moderate_members=True) @commands.has_permissions(moderate_members=True)
async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member): async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor() 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) await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="modconfig", description="Configuration de la modération") @commands.slash_command(name="modconfig", description="Configuration de la modération")
@check_premium_tier()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def modconfig(self, interaction: disnake.ApplicationCommandInteraction): async def modconfig(self, interaction: disnake.ApplicationCommandInteraction):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor() conn = sqlite3.connect(self.db_path); cursor = conn.cursor()

View file

@ -10,6 +10,7 @@ except ImportError:
import disnake import disnake
from disnake.ext import commands, tasks from disnake.ext import commands, tasks
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
PARIS_TZ = ZoneInfo("Europe/Paris") PARIS_TZ = ZoneInfo("Europe/Paris")
@ -906,6 +907,7 @@ class Rapports(commands.Cog):
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
@commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)") @commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)")
@check_premium_tier()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None): async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None):
guild_id = interaction.guild.id guild_id = interaction.guild.id
@ -938,10 +940,12 @@ class Rapports(commands.Cog):
await interaction.response.send_message(embed=embed, view=view, ephemeral=True) await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
@commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport") @commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport")
@check_premium_tier()
async def rapport(self, interaction: disnake.ApplicationCommandInteraction): async def rapport(self, interaction: disnake.ApplicationCommandInteraction):
await interaction.response.send_modal(ReportModal(self)) await interaction.response.send_modal(ReportModal(self))
@commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive") @commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive")
@check_premium_tier()
async def consulter_rapport( async def consulter_rapport(
self, self,
interaction: disnake.ApplicationCommandInteraction, interaction: disnake.ApplicationCommandInteraction,

View file

@ -5,6 +5,7 @@ import hashlib
import os import os
import json import json
import re import re
from utils.premium import check_premium_tier
MALWARE_DB = "malware.json" MALWARE_DB = "malware.json"
SUSPICIOUS_EXTENSIONS = [".exe", ".scr", ".bat", ".js", ".vbs", ".cmd"] SUSPICIOUS_EXTENSIONS = [".exe", ".scr", ".bat", ".js", ".vbs", ".cmd"]
@ -59,6 +60,7 @@ class Scan(commands.Cog):
self.bot = bot self.bot = bot
@commands.slash_command(name="scan", description="Scanner un fichier pour détecter un malware.") @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): async def scan(self, interaction: disnake.ApplicationCommandInteraction, file: disnake.Attachment):
await interaction.response.defer() # Permet de prendre un peu de temps pour le scan 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 json
import os import os
from typing import Optional from typing import Optional
from utils.premium import check_premium_tier
# Chemins ABSOLUS des fichiers (basés sur le fichier courant) # Chemins ABSOLUS des fichiers (basés sur le fichier courant)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@ -1029,6 +1030,7 @@ class Security(commands.Cog):
name="security", name="security",
description="🛡️ Ouvrir le panneau de sécurité Kuby" description="🛡️ Ouvrir le panneau de sécurité Kuby"
) )
@check_premium_tier()
async def security(self, interaction: disnake.ApplicationCommandInteraction): async def security(self, interaction: disnake.ApplicationCommandInteraction):
""" """
Commande principale pour ouvrir l'interface de sécurité. Commande principale pour ouvrir l'interface de sécurité.

View file

@ -1,5 +1,6 @@
import disnake import disnake
from disnake.ext import commands from disnake.ext import commands
from utils.premium import check_premium_tier
OWNER_ID = 971446412690722826 OWNER_ID = 971446412690722826
@ -124,6 +125,7 @@ class SentBotMessages(commands.Cog):
name="sentbotmessages", name="sentbotmessages",
description="Le bot envoie un message dans un salon choisi." description="Le bot envoie un message dans un salon choisi."
) )
@check_premium_tier()
async def sentbotmessages( async def sentbotmessages(
self, self,
interaction: disnake.ApplicationCommandInteraction, interaction: disnake.ApplicationCommandInteraction,
@ -154,6 +156,7 @@ class SentBotMessages(commands.Cog):
name="annonce_proprietaires", name="annonce_proprietaires",
description="Ouvrir le formulaire d'annonce pour les propriétaires." description="Ouvrir le formulaire d'annonce pour les propriétaires."
) )
@check_premium_tier()
@commands.check(is_authorized) @commands.check(is_authorized)
async def annonce_proprietaires( async def annonce_proprietaires(
self, self,

View file

@ -2,6 +2,7 @@ import disnake
from disnake.ext import commands from disnake.ext import commands
from datetime import datetime from datetime import datetime
from collections import deque from collections import deque
from utils.premium import check_premium_tier
class SnipeCommands(commands.Cog): class SnipeCommands(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
@ -34,6 +35,7 @@ class SnipeCommands(commands.Cog):
self.deleted_messages[channel_id].appendleft(message_data) self.deleted_messages[channel_id].appendleft(message_data)
@commands.slash_command(name="snipe", description="Voir les derniers messages supprimés d'un salon") @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, async def snipe(self, interaction: disnake.ApplicationCommandInteraction,
nombre: int = commands.Param(1, description="Nombre de messages à afficher (1-10, par défaut: 1)")): nombre: int = commands.Param(1, description="Nombre de messages à afficher (1-10, par défaut: 1)")):
"""Retrieve deleted messages from the current channel""" """Retrieve deleted messages from the current channel"""
@ -93,6 +95,7 @@ class SnipeCommands(commands.Cog):
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
@commands.slash_command(name="snipeclear", description="Effacer l'historique des messages supprimés pour ce salon") @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): async def snipeclear(self, interaction: disnake.ApplicationCommandInteraction):
"""Clear the deleted messages history for the current channel""" """Clear the deleted messages history for the current channel"""
channel_id = interaction.channel.id 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.data.storage import get_storage
from commandes.ticket.staff_analytics import StaffAnalytics from commandes.ticket.staff_analytics import StaffAnalytics
from utils.premium import check_premium_tier
class StaffLeaderboard(commands.Cog): class StaffLeaderboard(commands.Cog):
@ -11,6 +12,7 @@ class StaffLeaderboard(commands.Cog):
self.bot = bot self.bot = bot
@commands.slash_command(name="staff-leaderboard", description="Affiche le classement du staff") @commands.slash_command(name="staff-leaderboard", description="Affiche le classement du staff")
@check_premium_tier()
async def staff_leaderboard(self, interaction: disnake.ApplicationCommandInteraction): async def staff_leaderboard(self, interaction: disnake.ApplicationCommandInteraction):
"""Commande pour afficher le classement du staff""" """Commande pour afficher le classement du staff"""
await interaction.response.defer(ephemeral=False) 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.storage import get_storage
from commandes.ticket.data.models import TicketStatus from commandes.ticket.data.models import TicketStatus
from commandes.ticket.staff_analytics import StaffAnalytics from commandes.ticket.staff_analytics import StaffAnalytics
from utils.premium import check_premium_tier
class StaffRatings(commands.Cog): class StaffRatings(commands.Cog):
@ -19,6 +20,7 @@ class StaffRatings(commands.Cog):
self.bot = bot self.bot = bot
@commands.slash_command(name="staff-ratings", description="Affiche les évaluations du staff") @commands.slash_command(name="staff-ratings", description="Affiche les évaluations du staff")
@check_premium_tier()
async def staff_ratings(self, interaction: disnake.ApplicationCommandInteraction, async def staff_ratings(self, interaction: disnake.ApplicationCommandInteraction,
team: str = commands.Param(None, description="Filtrer par équipe (optionnel)")): team: str = commands.Param(None, description="Filtrer par équipe (optionnel)")):
"""Commande pour afficher les évaluations du staff""" """Commande pour afficher les évaluations du staff"""

View file

@ -18,6 +18,7 @@ from utils.gestion_taches import (
PRIORITES, STATUTS PRIORITES, STATUTS
) )
from src.logger import kuby_logger 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") @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): async def taches_command(self, inter: disnake.ApplicationCommandInteraction):
"""Commande principale pour accéder au menu des tâches.""" """Commande principale pour accéder au menu des tâches."""
settings = await get_taches_settings(inter.guild.id) settings = await get_taches_settings(inter.guild.id)

View file

@ -162,6 +162,7 @@ class TeamJSON(commands.Cog):
name="generate_team_json", name="generate_team_json",
description="Génère le JSON de l'équipe (admin only)" description="Génère le JSON de l'équipe (admin only)"
) )
@check_premium_tier()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction): async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction):
"""Commande manuelle pour (re)générer le JSON et les avatars.""" """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 .staff_analytics import StaffAnalytics, StaffStatsView
from .utils.permissions import is_staff, is_whitelisted, can_access_config from .utils.permissions import is_staff, is_whitelisted, can_access_config
from src.logger import kuby_logger from src.logger import kuby_logger
from utils.premium import check_premium_tier
# V2 RICH COMPONENTS LOADED - 14:10 # V2 RICH COMPONENTS LOADED - 14:10
kuby_logger.info("🎫 [TicketSystem] Chargement de la version V2 Rich Components...") 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 # Add a task for sending feedback reminders
class FeedbackReminderTask: class FeedbackReminderTask:
def __init__(self, bot): def __init__(self, bot):
@ -153,12 +157,8 @@ class TicketPanelView(disnake.ui.View):
async def _create_callback(self, interaction: disnake.Interaction): async def _create_callback(self, interaction: disnake.Interaction):
"""Affiche les catégories pour créer un ticket""" """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) 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: if not self.config.enabled:
await interaction.response.send_message("Le système de tickets est désactivé.", ephemeral=True) 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: if not self.config.categories:
await interaction.response.send_message("Aucune catégorie configurée. Utilisez la configuration.", ephemeral=True) await interaction.response.send_message("Aucune catégorie configurée. Utilisez la configuration.", ephemeral=True)
return return
# Build V2 Container for category selection components = build_category_selection_components(self.config)
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)}"
)
)
)
try: try:
await interaction.response.send_message( await interaction.response.send_message(
components=[disnake.ui.Container(*sections)], components=components,
flags=disnake.MessageFlags(is_components_v2=True), flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True ephemeral=True
) )
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Fallback: texte simple avec view legacy
if interaction.response.is_done(): if interaction.response.is_done():
await interaction.followup.send( 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), view=CategorySelectionView(self.bot, self.config),
ephemeral=True ephemeral=True
) )
else: else:
await interaction.response.send_message( 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), view=CategorySelectionView(self.bot, self.config),
ephemeral=True ephemeral=True
) )
@ -477,6 +458,104 @@ class TicketPanelView(disnake.ui.View):
return stats 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): class CategoryButton(disnake.ui.Button):
def __init__(self, category, view_instance): def __init__(self, category, view_instance):
super().__init__( super().__init__(
@ -502,7 +581,7 @@ class CategorySelectionView(disnake.ui.View):
self.id_gen = int(datetime.now().timestamp()) self.id_gen = int(datetime.now().timestamp())
# Ajouter boutons de catégories # 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)) self.add_item(CategoryButton(category, self))
def _is_staff(self, interaction: disnake.Interaction) -> bool: 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): async def _on_category_select(self, interaction: disnake.Interaction, category_id: str):
"""Handle category selection""" """Handle category selection"""
try: try:
await open_ticket_for_category(self.bot, interaction, self.config, category_id)
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)
except Exception as e: except Exception as e:
kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True) kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True)
try: try:
@ -720,10 +766,13 @@ class RecruitmentQuestionsModal(disnake.ui.Modal):
self.question_inputs = {} self.question_inputs = {}
for i, question in enumerate(category.questions): 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( input_field = disnake.ui.TextInput(
label=f"Question {i+1}", label=f"Question {i+1}", # <= 45 by construction
custom_id=f"recruitment_q_{i}", custom_id=f"recruitment_q_{i}",
placeholder=question, placeholder=_clamp_text(question, 100, ""),
required=True, required=True,
max_length=1000, max_length=1000,
style=disnake.TextInputStyle.paragraph style=disnake.TextInputStyle.paragraph
@ -1005,9 +1054,13 @@ class TicketManagementView(disnake.ui.View):
f"ticket_user={getattr(self.ticket,'user_id',None)}" f"ticket_user={getattr(self.ticket,'user_id',None)}"
) )
# Vérifier si l'utilisateur peut fermer le ticket # 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 = ( can_close = (
interaction.user.id == self.ticket.user_id or self._is_staff(interaction) or (
self._is_staff(interaction) interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN
)
) )
if not can_close: 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): async def confirm_callback(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
"""L'utilisateur confirme la fermeture""" """L'utilisateur confirme la fermeture"""
# Vérifier les permissions # 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 = ( can_close = (
interaction.user.id == self.ticket.user_id or self._is_staff(interaction) or (
self._is_staff(interaction) interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN
)
) )
if not can_close: if not can_close:
@ -2050,6 +2107,7 @@ class CategoriesSetupView(disnake.ui.View):
# Remove category # Remove category
self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id] self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id]
self.storage.save_config(interaction.guild_id, self.config) 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) 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) 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): class AddCategoryModal(disnake.ui.Modal):
"""Modal pour ajouter une catégorie""" """Modal pour ajouter une catégorie"""
def __init__(self, bot, storage, config: GuildTicketConfig): 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) # label max 45, placeholder max 100
self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph) name_label = _clamp_text("Nom", 45, "Nom")
self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5) 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 # Recruitment fields
self.is_recruitment_input = disnake.ui.TextInput( self.is_recruitment_input = disnake.ui.TextInput(
label="Recrutement? (oui/non)", label=recruitment_label,
custom_id="add_cat_recruitment", custom_id="add_cat_recruitment",
placeholder="non", placeholder=recruitment_placeholder,
required=False, 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( self.questions_input = disnake.ui.TextInput(
label="Questions recrutement (une par ligne)", label=questions_label, # <=45 enforced
custom_id="add_cat_questions", custom_id="add_cat_questions",
placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?", placeholder=questions_placeholder, # <=100 enforced
required=False, required=False,
style=disnake.TextInputStyle.paragraph 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
) )
super().__init__( super().__init__(
title="Ajouter une Catégorie", title="Ajouter une Catégorie",
components=[ components=[
@ -2336,9 +2452,9 @@ class AddCategoryModal(disnake.ui.Modal):
self.emoji_input, self.emoji_input,
self.is_recruitment_input, self.is_recruitment_input,
self.questions_input, self.questions_input,
self.responses_channel_input
] ]
) )
self.bot = bot self.bot = bot
self.storage = storage self.storage = storage
self.config = config self.config = config
@ -2353,13 +2469,23 @@ class AddCategoryModal(disnake.ui.Modal):
emoji_val = interaction.text_values.get("add_cat_emoji", "🎫") emoji_val = interaction.text_values.get("add_cat_emoji", "🎫")
is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non") is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non")
questions_val = interaction.text_values.get("add_cat_questions", "") 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"] 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 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( category = TicketCategory(
id=cat_id, id=cat_id,
@ -2368,11 +2494,14 @@ class AddCategoryModal(disnake.ui.Modal):
emoji=emoji_val, emoji=emoji_val,
is_recruitment=is_recruitment, is_recruitment=is_recruitment,
questions=questions, 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.invalidate_cache(interaction.guild_id)
self.storage.save_config(interaction.guild_id, self.config) 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 = "" recruitment_info = ""
if is_recruitment: if is_recruitment:
@ -2380,7 +2509,18 @@ class AddCategoryModal(disnake.ui.Modal):
if responses_channel_id: if responses_channel_id:
recruitment_info += f"\n📝 Salon réponses: <#{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}", f"Catégorie **{name_val}** créée!{recruitment_info}",
ephemeral=True ephemeral=True
) )
@ -2406,6 +2546,7 @@ class EditCategoryModal(disnake.ui.Modal):
self.category.description = interaction.text_values.get("edit_cat_desc", "") self.category.description = interaction.text_values.get("edit_cat_desc", "")
self.category.emoji = interaction.text_values.get("edit_cat_emoji", "🎫") self.category.emoji = interaction.text_values.get("edit_cat_emoji", "🎫")
self.storage.save_config(interaction.guild_id, self.config) self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
container = disnake.ui.Container( container = disnake.ui.Container(
disnake.ui.TextDisplay(content=f"✅ Catégorie **{self.category.name}** mise à jour !"), disnake.ui.TextDisplay(content=f"✅ Catégorie **{self.category.name}** mise à jour !"),
@ -2507,17 +2648,22 @@ class CategoryManagerView(disnake.ui.View):
description=desc_val, description=desc_val,
emoji=emoji_val emoji=emoji_val
) )
self.config.categories.append(category) self.storage.invalidate_cache(interaction.guild_id)
self.storage.save_config(interaction.guild_id, self.config) 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 # Refresh parent view
self.parent_view._add_category_select() 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 # On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed
await interaction.response.edit_message( await interaction.response.edit_message(
embed=self.parent_view._get_embed(), embed=self.parent_view._get_embed(),
view=self.parent_view 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) modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
@ -2667,6 +2813,7 @@ class DeleteCategoryConfirmationView(disnake.ui.View):
if self.category in self.config.categories: if self.category in self.config.categories:
self.config.categories.remove(self.category) self.config.categories.remove(self.category)
self.storage.save_config(interaction.guild_id, self.config) 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() self.parent_view._add_category_select()
await interaction.response.edit_message( 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} # ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = catv2_{cat.id}_{snowflake}
if custom_id.startswith("catv2_"): if custom_id.startswith("catv2_"):
try: try:
# format: catv2_<catid>_<suffix>
parts = custom_id.split("_") parts = custom_id.split("_")
if len(parts) >= 3: cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None)
cat_id = "_".join(parts[1:-1]) await open_ticket_for_category(self.bot, inter, config, cat_id)
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)
except Exception as e: 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(): if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
@ -3225,56 +3352,83 @@ class TicketCommands(commands.Cog):
try: try:
parts = str(custom_id).split("_") parts = str(custom_id).split("_")
cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None) 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)
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)
except Exception as e: 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(): if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
elif custom_id == "ticket_create": elif custom_id == "ticket_create":
# Construction du layout de sélection Premium V2 # Debug stockage/cache (pour diagnostiquer "catégorie non trouvée")
category_items = [ try:
disnake.ui.TextDisplay( kuby_logger.info(f"[TicketSystem][ticket_create] guild.id={getattr(inter.guild,'id',None)} guild_id={getattr(inter,'guild_id',None)}")
content="# 📂 Sélectionner une Catégorie - Omega Kube\n\n" except Exception:
"Veuillez choisir la catégorie qui correspond au motif de votre demande pour ouvrir un ticket." 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]]}"
) )
] except Exception:
pass
# Génération dynamique des sections avec Emojis et Descriptions configurées
for cat in config.categories[:5]: if not config.categories:
cat_emoji = getattr(cat, "emoji", "") or "" # Au cas où cache désynchronisé => invalider aussi avec guild_id
cat_desc = getattr(cat, "description", "") or getattr(cat, "desc", "") or "" try:
self.storage.invalidate_cache(inter.guild_id)
category_items.append(disnake.ui.Separator(divider=True)) config = self.storage.load_config(inter.guild_id)
category_items.append( cats = getattr(config, "categories", None) or []
disnake.ui.Section( kuby_logger.info(f"[TicketSystem][ticket_create] (after invalidate guild_id) categories_len={len(cats)}")
f"### {cat_emoji} {cat.name}\n{cat_desc}" if cat_desc else f"### {cat_emoji} {cat.name}", except Exception:
accessory=disnake.ui.Button( pass
label="Choisir",
style=disnake.ButtonStyle.blurple, if not config.categories:
custom_id=f"cat_{cat.id}_select" 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
) )
except Exception as e:
await inter.response.send_message( kuby_logger.error(f"[TicketSystem] ticket_create UI error: {e}", exc_info=True)
components=[disnake.ui.Container(*category_items)], if inter.response.is_done():
ephemeral=True 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": elif custom_id == "ticket_my_tickets":
# Afficher les tickets de l'utilisateur # Afficher les tickets de l'utilisateur
user_tickets = self.storage.get_user_tickets(inter.guild.id, inter.user.id) user_tickets = self.storage.get_user_tickets(inter.guild.id, inter.user.id)
@ -3341,7 +3495,16 @@ class TicketCommands(commands.Cog):
return return
# Vérifier que c'est du staff # 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) await inter.response.send_message("Réservé au staff.", ephemeral=True)
return return
@ -3417,9 +3580,13 @@ class TicketCommands(commands.Cog):
await inter.response.send_message("Ticket non trouvé.", ephemeral=True) await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
return return
# Règle voulue:
# - Staff: toujours autorisé
# - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN)
can_close = ( can_close = (
inter.user.id == ticket.user_id or is_staff(inter, config, config.get_category(ticket.category_id)) or (
is_staff(inter, config, config.get_category(ticket.category_id)) inter.user.id == ticket.user_id and ticket.status == TicketStatus.OPEN
)
) )
if not can_close: if not can_close:
await inter.response.send_message("Vous ne pouvez pas fermer ce ticket.", ephemeral=True) await inter.response.send_message("Vous ne pouvez pas fermer ce ticket.", ephemeral=True)
@ -3568,33 +3735,15 @@ class TicketCommands(commands.Cog):
return return
elif custom_id.startswith("ticket_config_cat_del_"): elif custom_id.startswith("ticket_config_cat_del_"):
# Le bouton "✅ Confirmer" a custom_id="ticket_config_cat_del_confirm_title" # Ne pas traiter ticket_config_cat_del_confirm_title ici :
# et ne doit pas être traité comme un "legacy delete cat" (sinon cat_id=confirm_title). # 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": 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) config = self.storage.load_config(inter.guild.id)
if not config: if config and config.categories is not None:
await inter.response.send_message("Erreur: config introuvable.", ephemeral=True) view = CategoryManagerView(self.bot, self.storage, config)
return await inter.response.edit_message(components=view.get_components_v2())
# ⚠️ 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())
return return
# Recharger config pour éviter les mismatch (panel vs état storage) # 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) await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True)
# === COMMANDES === # === COMMANDES ===
@commands.slash_command(name="ticket", description="Ouvre le panel des tickets") @commands.slash_command(name="ticket", description="Ouvre le panel des tickets")
@check_premium_tier()
async def ticket_main(self, inter: disnake.ApplicationCommandInteraction): async def ticket_main(self, inter: disnake.ApplicationCommandInteraction):
"""Ouvre le panel des tickets""" """Ouvre le panel des tickets"""
config = self.storage.load_config(inter.guild.id) config = self.storage.load_config(inter.guild.id)
view = TicketPanelView(self.bot, config, self.storage) view = TicketPanelView(self.bot, config, self.storage)
await inter.response.send_message(components=view.get_components_v2(), ephemeral=True) 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") @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) @commands.has_permissions(administrator=True)
async def ticket_panel(self, inter: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel = None): async def ticket_panel(self, inter: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel = None):
"""Envoie le panel de création de tickets dans un canal""" """Envoie le panel de création de tickets dans un canal"""
@ -3826,11 +3978,16 @@ class TicketCommands(commands.Cog):
target = channel or inter.channel target = channel or inter.channel
view = TicketPanelView(self.bot, config, self.storage) 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}") 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") @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): async def ticket_config(self, inter: disnake.ApplicationCommandInteraction):
"""Ouvre le panel de configuration complet du système de tickets""" """Ouvre le panel de configuration complet du système de tickets"""
await inter.response.defer(ephemeral=True) await inter.response.defer(ephemeral=True)

View file

@ -301,6 +301,7 @@ class GuildTicketConfig:
claim_enabled: Whether claiming is enabled by default claim_enabled: Whether claiming is enabled by default
survey_enabled: Whether surveys are enabled by default survey_enabled: Whether surveys are enabled by default
panel_channel_id: Channel for the ticket panel 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 welcome_enabled: Whether welcome messages are shown
priority_enabled: Whether priority selection is enabled priority_enabled: Whether priority selection is enabled
""" """
@ -322,6 +323,7 @@ class GuildTicketConfig:
claim_enabled: bool = True claim_enabled: bool = True
survey_enabled: bool = True survey_enabled: bool = True
panel_channel_id: Optional[int] = None panel_channel_id: Optional[int] = None
panel_message_id: Optional[int] = None
welcome_enabled: bool = True welcome_enabled: bool = True
priority_enabled: bool = True priority_enabled: bool = True
@ -346,6 +348,7 @@ class GuildTicketConfig:
"claim_enabled": self.claim_enabled, "claim_enabled": self.claim_enabled,
"survey_enabled": self.survey_enabled, "survey_enabled": self.survey_enabled,
"panel_channel_id": self.panel_channel_id, "panel_channel_id": self.panel_channel_id,
"panel_message_id": self.panel_message_id,
"welcome_enabled": self.welcome_enabled, "welcome_enabled": self.welcome_enabled,
"priority_enabled": self.priority_enabled "priority_enabled": self.priority_enabled
} }
@ -372,6 +375,7 @@ class GuildTicketConfig:
claim_enabled=data.get("claim_enabled", True), claim_enabled=data.get("claim_enabled", True),
survey_enabled=data.get("survey_enabled", True), survey_enabled=data.get("survey_enabled", True),
panel_channel_id=data.get("panel_channel_id"), panel_channel_id=data.get("panel_channel_id"),
panel_message_id=data.get("panel_message_id"),
welcome_enabled=data.get("welcome_enabled", True), welcome_enabled=data.get("welcome_enabled", True),
priority_enabled=data.get("priority_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 from src.logger import kuby_logger
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from utils.premium import check_premium_tier
# Dictionnaire global pour suivre les renommages de salons # Dictionnaire global pour suivre les renommages de salons
# Format: {channel_id: [timestamp1, timestamp2, ...]} # Format: {channel_id: [timestamp1, timestamp2, ...]}
@ -1586,6 +1587,7 @@ class Ticket(commands.Cog):
self.bot.add_view(ClosedTicketView()) self.bot.add_view(ClosedTicketView())
@commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.") @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): async def ticket_whitelist(self, interaction: disnake.ApplicationCommandInteraction):
"""Envoie l'embed avec le bouton d'ouverture de ticket""" """Envoie l'embed avec le bouton d'ouverture de ticket"""
if not is_staff_or_admin(interaction.user, interaction.guild): 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) 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)") @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): async def ticket_whitelist_config(self, interaction: disnake.ApplicationCommandInteraction):
"""Interface de configuration des tickets en Containers V2""" """Interface de configuration des tickets en Containers V2"""
if not is_staff_or_admin(interaction.user, interaction.guild): if not is_staff_or_admin(interaction.user, interaction.guild):

View file

@ -7,6 +7,7 @@ import os
import logging import logging
import asyncio import asyncio
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
@ -345,6 +346,7 @@ class Welcome(commands.Cog):
return os.path.basename(banner_path) 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") @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) @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): 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) # ✅ 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) await interaction.followup.send(error_message, ephemeral=True)
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations") @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): async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
user = user or interaction.author user = user or interaction.author
await self._send_invitation_stats(interaction, user) await self._send_invitation_stats(interaction, user)

View file

@ -3,6 +3,7 @@ from disnake.ext import commands
import json import json
import os import os
from typing import List from typing import List
from utils.premium import check_premium_tier
# Chemins ABSOLUS des fichiers # Chemins ABSOLUS des fichiers
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@ -38,6 +39,7 @@ class Whitelist(commands.Cog):
self.save_whitelist() self.save_whitelist()
@commands.slash_command(name="whitelist", description="Gère la whitelist du serveur") @commands.slash_command(name="whitelist", description="Gère la whitelist du serveur")
@check_premium_tier()
async def whitelist_group(self, interaction: disnake.ApplicationCommandInteraction): async def whitelist_group(self, interaction: disnake.ApplicationCommandInteraction):
"""Groupe de commandes pour gérer la whitelist""" """Groupe de commandes pour gérer la whitelist"""
pass 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
]
}