Compare commits

..

4 commits
main ... dev

36 changed files with 1327 additions and 626 deletions

15
TODO.md Normal file
View file

@ -0,0 +1,15 @@
# TODO - Désactivation fonctionnalités premium (SKU) & correction accès /ticket
## Étape 1 — Compréhension
- [x] Identifier le check premium bloquant /ticket (utils/premium.py)
- [x] Vérifier où le check est appliqué (commandes/ticket/__init__.py)
## Étape 2 — Mise en conformité
- [x] Désactivation temporaire via env var : DISABLE_PREMIUM_SKU_CHECK=true
- [ ] Ajouter un switch clair pour ré-activer facilement (déjà géré par la même env var)
## Étape 3 — Validation
- [ ] Lancer un lint/tests rapides ou démarrer le bot (si possible)
- [ ] Vérifier que /ticket répond sur le serveur bloqué (Omega Kube)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -38,10 +38,5 @@
"requester_id": 971446412690722826,
"last_note_id": 0,
"type": "legacy_report"
},
"None": {
"requester_id": 971446412690722826,
"last_note_id": 0,
"type": "manual-report"
}
}

View file

@ -7,6 +7,7 @@ import os
import logging
from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timezone
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot")
@ -285,6 +286,7 @@ class Goodbye(commands.Cog):
return os.path.basename(banner_path)
@commands.slash_command(name="setgoodbye", description="Définit le salon d'au revoir et les paramètres personnalisés")
@check_premium_tier()
@commands.has_permissions(administrator=True)
async def set_goodbye(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, server_name: str = None, message: str = None, banner_file: disnake.Attachment = None):
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)

View file

@ -5,6 +5,7 @@ import os
import logging
from typing import Dict, Optional
import asyncio
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot")
@ -191,11 +192,16 @@ class Invites(commands.Cog):
current_present = 0
for mid in invited_ids:
if guild.get_member(mid):
member = guild.get_member(mid)
if member is not None:
# "membres présents" doit refléter la présence réelle : on considère présent
# uniquement les membres qui sont encore sur le serveur.
current_present += 1
else:
kuby_logger.debug(f"[Invites] Membre invité {mid} non trouvé dans le serveur {guild_id}")
# Total = tous les invités enregistrés pour cet inviteur.
# "présents" = ceux qui sont encore sur le serveur (donc baisse quand quelqu'un quitte).
return len(invited_ids), current_present
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la récupération des statistiques d'invitations pour {user_id}: {e}")
@ -229,6 +235,7 @@ class Invites(commands.Cog):
kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {user.name}: {e}")
@commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations")
@check_premium_tier()
@commands.has_permissions(administrator=True)
async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel):
try:

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import hashlib
import os
import json
import re
from utils.premium import check_premium_tier
MALWARE_DB = "malware.json"
SUSPICIOUS_EXTENSIONS = [".exe", ".scr", ".bat", ".js", ".vbs", ".cmd"]
@ -59,6 +60,7 @@ class Scan(commands.Cog):
self.bot = bot
@commands.slash_command(name="scan", description="Scanner un fichier pour détecter un malware.")
@check_premium_tier()
async def scan(self, interaction: disnake.ApplicationCommandInteraction, file: disnake.Attachment):
await interaction.response.defer() # Permet de prendre un peu de temps pour le scan

View file

@ -3,6 +3,7 @@ from disnake.ext import commands
import json
import os
from typing import Optional
from utils.premium import check_premium_tier
# Chemins ABSOLUS des fichiers (basés sur le fichier courant)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@ -1029,6 +1030,7 @@ class Security(commands.Cog):
name="security",
description="🛡️ Ouvrir le panneau de sécurité Kuby"
)
@check_premium_tier()
async def security(self, interaction: disnake.ApplicationCommandInteraction):
"""
Commande principale pour ouvrir l'interface de sécurité.

View file

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

View file

@ -2,6 +2,7 @@ import disnake
from disnake.ext import commands
from datetime import datetime
from collections import deque
from utils.premium import check_premium_tier
class SnipeCommands(commands.Cog):
def __init__(self, bot):
@ -34,6 +35,7 @@ class SnipeCommands(commands.Cog):
self.deleted_messages[channel_id].appendleft(message_data)
@commands.slash_command(name="snipe", description="Voir les derniers messages supprimés d'un salon")
@check_premium_tier()
async def snipe(self, interaction: disnake.ApplicationCommandInteraction,
nombre: int = commands.Param(1, description="Nombre de messages à afficher (1-10, par défaut: 1)")):
"""Retrieve deleted messages from the current channel"""
@ -93,6 +95,7 @@ class SnipeCommands(commands.Cog):
await interaction.response.send_message(embed=embed)
@commands.slash_command(name="snipeclear", description="Effacer l'historique des messages supprimés pour ce salon")
@check_premium_tier()
async def snipeclear(self, interaction: disnake.ApplicationCommandInteraction):
"""Clear the deleted messages history for the current channel"""
channel_id = interaction.channel.id

View file

@ -4,6 +4,7 @@ from datetime import datetime
from commandes.ticket.data.storage import get_storage
from commandes.ticket.staff_analytics import StaffAnalytics
from utils.premium import check_premium_tier
class StaffLeaderboard(commands.Cog):
@ -11,6 +12,7 @@ class StaffLeaderboard(commands.Cog):
self.bot = bot
@commands.slash_command(name="staff-leaderboard", description="Affiche le classement du staff")
@check_premium_tier()
async def staff_leaderboard(self, interaction: disnake.ApplicationCommandInteraction):
"""Commande pour afficher le classement du staff"""
await interaction.response.defer(ephemeral=False)

View file

@ -12,6 +12,7 @@ from collections import defaultdict
from commandes.ticket.data.storage import get_storage
from commandes.ticket.data.models import TicketStatus
from commandes.ticket.staff_analytics import StaffAnalytics
from utils.premium import check_premium_tier
class StaffRatings(commands.Cog):
@ -19,6 +20,7 @@ class StaffRatings(commands.Cog):
self.bot = bot
@commands.slash_command(name="staff-ratings", description="Affiche les évaluations du staff")
@check_premium_tier()
async def staff_ratings(self, interaction: disnake.ApplicationCommandInteraction,
team: str = commands.Param(None, description="Filtrer par équipe (optionnel)")):
"""Commande pour afficher les évaluations du staff"""

View file

@ -18,6 +18,7 @@ from utils.gestion_taches import (
PRIORITES, STATUTS
)
from src.logger import kuby_logger
from utils.premium import check_premium_tier
# ============================================================================
@ -655,6 +656,7 @@ class TachesCog(commands.Cog):
# ==========================================================================
@commands.slash_command(name="taches", description="Ouvrir le menu de gestion des tâches")
@check_premium_tier()
async def taches_command(self, inter: disnake.ApplicationCommandInteraction):
"""Commande principale pour accéder au menu des tâches."""
settings = await get_taches_settings(inter.guild.id)

View file

@ -162,6 +162,7 @@ class TeamJSON(commands.Cog):
name="generate_team_json",
description="Génère le JSON de l'équipe (admin only)"
)
@check_premium_tier()
@commands.has_permissions(administrator=True)
async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction):
"""Commande manuelle pour (re)générer le JSON et les avatars."""

View file

@ -38,9 +38,13 @@ from .utils.formatters import TicketEmbedFormatter
from .staff_analytics import StaffAnalytics, StaffStatsView
from .utils.permissions import is_staff, is_whitelisted, can_access_config
from src.logger import kuby_logger
from utils.premium import check_premium_tier
# V2 RICH COMPONENTS LOADED - 14:10
kuby_logger.info("🎫 [TicketSystem] Chargement de la version V2 Rich Components...")
# Discord limite le nombre de composants par message ; 25 couvre les selects Discord.
MAX_PANEL_CATEGORIES = 25
# Add a task for sending feedback reminders
class FeedbackReminderTask:
def __init__(self, bot):
@ -153,12 +157,8 @@ class TicketPanelView(disnake.ui.View):
async def _create_callback(self, interaction: disnake.Interaction):
"""Affiche les catégories pour créer un ticket"""
# Reload config to ensure we have latest permissions/categories
self.storage.invalidate_cache(interaction.guild_id)
self.config = self.storage.load_config(interaction.guild_id)
# Reload config to ensure we have latest permissions/categories
self.config = self.storage.load_config(interaction.guild_id)
if not self.config.enabled:
await interaction.response.send_message("Le système de tickets est désactivé.", ephemeral=True)
@ -167,45 +167,26 @@ class TicketPanelView(disnake.ui.View):
if not self.config.categories:
await interaction.response.send_message("Aucune catégorie configurée. Utilisez la configuration.", ephemeral=True)
return
# Build V2 Container for category selection
sections = [
disnake.ui.TextDisplay(content="## 🎫 Créer un Ticket"),
disnake.ui.Separator(),
]
for cat in self.config.categories[:5]:
desc = cat.description or "Aucune description"
sections.append(
disnake.ui.Section(
f"**{cat.emoji} {cat.name}**\n{desc}",
accessory=disnake.ui.Button(
label=cat.name,
style=disnake.ButtonStyle.primary,
custom_id=f"catv2_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}"
)
)
)
components = build_category_selection_components(self.config)
try:
await interaction.response.send_message(
components=[disnake.ui.Container(*sections)],
components=components,
flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True
)
except Exception as e:
import traceback
traceback.print_exc()
# Fallback: texte simple avec view legacy
if interaction.response.is_done():
await interaction.followup.send(
f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, self.config),
ephemeral=True
)
else:
await interaction.response.send_message(
f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, self.config),
ephemeral=True
)
@ -477,6 +458,104 @@ class TicketPanelView(disnake.ui.View):
return stats
async def refresh_ticket_panel(bot, storage, guild_id: int) -> bool:
"""Met à jour le message du panel de création de tickets s'il est enregistré."""
config = storage.load_config(guild_id)
if not config.panel_channel_id or not config.panel_message_id:
return False
try:
channel = bot.get_channel(config.panel_channel_id)
if channel is None:
channel = await bot.fetch_channel(config.panel_channel_id)
message = await channel.fetch_message(config.panel_message_id)
view = TicketPanelView(bot, config, storage)
await message.edit(components=view.get_components_v2())
return True
except Exception as e:
kuby_logger.warning(f"[TicketSystem] Échec du rafraîchissement du panel: {e}")
return False
def build_category_selection_components(config: GuildTicketConfig) -> list:
"""Construit le sélecteur de catégories (menu déroulant, jusqu'à 25 options).
Un Container V2 ne peut contenir que 10 composants enfants ; une Section+Separator
par catégorie dépassait rapidement cette limite. Le menu déroulant affiche toutes
les catégories dans un seul composant.
"""
categories = config.categories[:MAX_PANEL_CATEGORIES]
if not categories:
return []
options = []
for cat in categories:
option_kwargs = {
"label": cat.name[:100],
"value": str(cat.id)[:100],
}
description = (cat.description or "").strip()
if description:
option_kwargs["description"] = description[:100]
if cat.emoji:
option_kwargs["emoji"] = cat.emoji
options.append(disnake.SelectOption(**option_kwargs))
category_select = disnake.ui.StringSelect(
custom_id="ticket_category_select",
placeholder="Choisissez une catégorie...",
options=options,
min_values=1,
max_values=1,
)
return [
disnake.ui.Container(
disnake.ui.TextDisplay(
content="# 📂 Créer un Ticket\n\n"
"Sélectionnez la catégorie correspondant à votre demande."
),
disnake.ui.ActionRow(category_select),
)
]
async def open_ticket_for_category(bot, interaction: disnake.Interaction, config: GuildTicketConfig, category_id: str):
"""Ouvre le modal de création de ticket pour la catégorie choisie."""
category = config.get_category(category_id)
if not category:
await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
storage = get_storage()
user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True)
active_tickets = []
for ticket in user_tickets:
if interaction.guild.get_channel(ticket.channel_id):
active_tickets.append(ticket)
else:
ticket.status = TicketStatus.CLOSED
storage.save_ticket(ticket)
if len(active_tickets) >= config.max_tickets_per_user:
await interaction.response.send_message(
f"Vous avez atteint la limite de {config.max_tickets_per_user} tickets.",
ephemeral=True
)
return
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(bot, config, category)
else:
modal = TicketPriorityModal(bot, config, category)
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await interaction.response.send_modal(modal)
class CategoryButton(disnake.ui.Button):
def __init__(self, category, view_instance):
super().__init__(
@ -502,7 +581,7 @@ class CategorySelectionView(disnake.ui.View):
self.id_gen = int(datetime.now().timestamp())
# Ajouter boutons de catégories
for category in config.categories[:5]:
for category in config.categories[:MAX_PANEL_CATEGORIES]:
self.add_item(CategoryButton(category, self))
def _is_staff(self, interaction: disnake.Interaction) -> bool:
@ -517,40 +596,7 @@ class CategorySelectionView(disnake.ui.View):
async def _on_category_select(self, interaction: disnake.Interaction, category_id: str):
"""Handle category selection"""
try:
category = self.config.get_category(category_id)
if not category:
await interaction.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
storage = get_storage()
# Check limits
user_tickets = storage.get_user_tickets(interaction.guild_id, interaction.user.id, open_only=True)
active_tickets = []
for t in user_tickets:
if interaction.guild.get_channel(t.channel_id):
active_tickets.append(t)
else:
# Automatically clean up phantom tickets
t.status = TicketStatus.CLOSED
storage.save_ticket(t)
if len(active_tickets) >= self.config.max_tickets_per_user:
await interaction.response.send_message(
f"Vous avez atteint la limite de {self.config.max_tickets_per_user} tickets.",
ephemeral=True
)
return
# Check if this is a recruitment category
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(self.bot, self.config, category)
else:
modal = TicketPriorityModal(self.bot, self.config, category)
await interaction.response.send_modal(modal)
await open_ticket_for_category(self.bot, interaction, self.config, category_id)
except Exception as e:
kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True)
try:
@ -720,10 +766,13 @@ class RecruitmentQuestionsModal(disnake.ui.Modal):
self.question_inputs = {}
for i, question in enumerate(category.questions):
# Discord constraints:
# - placeholder length <= 100
# We clamp the dynamic placeholder (questions come from config).
input_field = disnake.ui.TextInput(
label=f"Question {i+1}",
label=f"Question {i+1}", # <= 45 by construction
custom_id=f"recruitment_q_{i}",
placeholder=question,
placeholder=_clamp_text(question, 100, ""),
required=True,
max_length=1000,
style=disnake.TextInputStyle.paragraph
@ -1005,9 +1054,13 @@ class TicketManagementView(disnake.ui.View):
f"ticket_user={getattr(self.ticket,'user_id',None)}"
)
# Vérifier si l'utilisateur peut fermer le ticket
# Règle voulue:
# - Staff: toujours autorisé
# - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN)
can_close = (
interaction.user.id == self.ticket.user_id or
self._is_staff(interaction)
self._is_staff(interaction) or (
interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN
)
)
if not can_close:
@ -1222,9 +1275,13 @@ class CloseConfirmationView(disnake.ui.View):
async def confirm_callback(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
"""L'utilisateur confirme la fermeture"""
# Vérifier les permissions
# Règle voulue:
# - Staff: toujours autorisé
# - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN)
can_close = (
interaction.user.id == self.ticket.user_id or
self._is_staff(interaction)
self._is_staff(interaction) or (
interaction.user.id == self.ticket.user_id and self.ticket.status == TicketStatus.OPEN
)
)
if not can_close:
@ -2050,6 +2107,7 @@ class CategoriesSetupView(disnake.ui.View):
# Remove category
self.config.categories = [cat for cat in self.config.categories if cat.id != cat_id]
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
await interaction.response.send_message("✅ Catégorie supprimée!", ephemeral=True)
@ -2300,34 +2358,92 @@ class ConfigPanelView(disnake.ui.View):
await interaction.response.send_modal(modal)
def _clamp_text(s: Any, max_len: int, default: str = "") -> str:
"""Discord TextInput constraints:
- label: 1..45
- placeholder: <=100
"""
if s is None:
return default
s = str(s)
if max_len <= 0:
return ""
if len(s) > max_len:
s = s[:max_len]
return s
class AddCategoryModal(disnake.ui.Modal):
"""Modal pour ajouter une catégorie"""
def __init__(self, bot, storage, config: GuildTicketConfig):
self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True)
self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph)
self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5)
# label max 45, placeholder max 100
name_label = _clamp_text("Nom", 45, "Nom")
name_placeholder = _clamp_text("Ex: Support", 100, "")
desc_label = _clamp_text("Description", 45, "Description")
desc_placeholder = _clamp_text("Description...", 100, "")
emoji_label = _clamp_text("Emoji", 45, "Emoji")
emoji_placeholder = _clamp_text("🎫", 100, "🎫")
recruitment_label = _clamp_text("Recrutement? (oui/non)", 45, "Recrutement?")
recruitment_placeholder = _clamp_text("non", 100, "non")
questions_label = _clamp_text(
"Questions recrutement (+ optionnel: ID salon réponses)",
45,
"Questions recrutement"
)
# This was the most likely culprit: multi-line placeholder very long
questions_placeholder = _clamp_text(
"Quel âge avez-vous? / Pourquoi vouloir rejoindre? / Expériences? / Optionnel: ID salon réponses: 123456789012345678",
100,
""
)
self.name_input = disnake.ui.TextInput(
label=name_label,
custom_id="add_cat_name",
placeholder=name_placeholder,
required=True,
)
self.desc_input = disnake.ui.TextInput(
label=desc_label,
custom_id="add_cat_desc",
placeholder=desc_placeholder,
required=False,
style=disnake.TextInputStyle.paragraph,
)
self.emoji_input = disnake.ui.TextInput(
label=emoji_label,
custom_id="add_cat_emoji",
placeholder=emoji_placeholder,
required=False,
max_length=5,
)
# Recruitment fields
self.is_recruitment_input = disnake.ui.TextInput(
label="Recrutement? (oui/non)",
label=recruitment_label,
custom_id="add_cat_recruitment",
placeholder="non",
placeholder=recruitment_placeholder,
required=False,
max_length=5
max_length=5,
)
# IMPORTANT:
# Disnake Modal has a maximum number of components.
# Keep this modal <= 5 components by merging "questions" + "responses channel id" into one input.
self.questions_input = disnake.ui.TextInput(
label="Questions recrutement (une par ligne)",
label=questions_label, # <=45 enforced
custom_id="add_cat_questions",
placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?",
placeholder=questions_placeholder, # <=100 enforced
required=False,
style=disnake.TextInputStyle.paragraph
)
self.responses_channel_input = disnake.ui.TextInput(
label="ID salon pour les réponses",
custom_id="add_cat_responses_channel",
placeholder="ID du salon staff où envoyer les réponses",
required=False,
max_length=20
style=disnake.TextInputStyle.paragraph,
)
super().__init__(
title="Ajouter une Catégorie",
components=[
@ -2336,9 +2452,9 @@ class AddCategoryModal(disnake.ui.Modal):
self.emoji_input,
self.is_recruitment_input,
self.questions_input,
self.responses_channel_input
]
)
self.bot = bot
self.storage = storage
self.config = config
@ -2353,13 +2469,23 @@ class AddCategoryModal(disnake.ui.Modal):
emoji_val = interaction.text_values.get("add_cat_emoji", "🎫")
is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non")
questions_val = interaction.text_values.get("add_cat_questions", "")
responses_channel_val = interaction.text_values.get("add_cat_responses_channel", "")
is_recruitment = is_recruitment_val.lower() in ["oui", "yes", "o", "y", "1", "true"]
questions = [q.strip() for q in questions_val.split("\n") if q.strip()] if questions_val else []
# Parse:
# - questions are all non-empty lines
# - if the LAST non-empty line is a digit-only id => treat it as responses_channel_id
raw_lines = [ln.strip() for ln in (questions_val or "").split("\n") if ln.strip()]
responses_channel_id = None
if responses_channel_val and responses_channel_val.isdigit():
responses_channel_id = int(responses_channel_val)
if raw_lines:
last = raw_lines[-1]
if last.isdigit():
# treat last line as channel id, remove it from questions
responses_channel_id = int(last)
raw_lines = raw_lines[:-1]
questions = raw_lines if raw_lines else []
category = TicketCategory(
id=cat_id,
@ -2368,11 +2494,14 @@ class AddCategoryModal(disnake.ui.Modal):
emoji=emoji_val,
is_recruitment=is_recruitment,
questions=questions,
responses_channel_id=responses_channel_id
responses_channel_id=responses_channel_id if is_recruitment else None
)
self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config)
self.storage.invalidate_cache(interaction.guild_id)
config = self.storage.load_config(interaction.guild_id)
config.categories.append(category)
self.storage.save_config(interaction.guild_id, config)
self.config = config
recruitment_info = ""
if is_recruitment:
@ -2380,7 +2509,18 @@ class AddCategoryModal(disnake.ui.Modal):
if responses_channel_id:
recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>"
await interaction.response.send_message(
await interaction.response.defer(ephemeral=True)
if interaction.message:
try:
manager_view = CategoryManagerView(self.bot, self.storage, config)
await interaction.message.edit(components=manager_view.get_components_v2())
except Exception as e:
kuby_logger.warning(f"[AddCategoryModal] Impossible de rafraîchir la liste des catégories: {e}")
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
await interaction.followup.send(
f"Catégorie **{name_val}** créée!{recruitment_info}",
ephemeral=True
)
@ -2406,6 +2546,7 @@ class EditCategoryModal(disnake.ui.Modal):
self.category.description = interaction.text_values.get("edit_cat_desc", "")
self.category.emoji = interaction.text_values.get("edit_cat_emoji", "🎫")
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
container = disnake.ui.Container(
disnake.ui.TextDisplay(content=f"✅ Catégorie **{self.category.name}** mise à jour !"),
@ -2507,17 +2648,22 @@ class CategoryManagerView(disnake.ui.View):
description=desc_val,
emoji=emoji_val
)
self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config)
self.storage.invalidate_cache(interaction.guild_id)
config = self.storage.load_config(interaction.guild_id)
config.categories.append(category)
self.storage.save_config(interaction.guild_id, config)
self.config = config
self.parent_view.config = config
# Refresh parent view
self.parent_view._add_category_select()
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
# On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed
await interaction.response.edit_message(
embed=self.parent_view._get_embed(),
view=self.parent_view
)
await interaction.followup.send(f"Catégorie **{self.name.value}** créée!", ephemeral=True)
await interaction.followup.send(f"Catégorie **{name_val}** créée!", ephemeral=True)
modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self)
await interaction.response.send_modal(modal)
@ -2667,6 +2813,7 @@ class DeleteCategoryConfirmationView(disnake.ui.View):
if self.category in self.config.categories:
self.config.categories.remove(self.category)
self.storage.save_config(interaction.guild_id, self.config)
await refresh_ticket_panel(self.bot, self.storage, interaction.guild_id)
self.parent_view._add_category_select()
await interaction.response.edit_message(
@ -3191,31 +3338,11 @@ class TicketCommands(commands.Cog):
# ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = catv2_{cat.id}_{snowflake}
if custom_id.startswith("catv2_"):
try:
# format: catv2_<catid>_<suffix>
parts = custom_id.split("_")
if len(parts) >= 3:
cat_id = "_".join(parts[1:-1])
else:
cat_id = parts[1] if len(parts) > 1 else None
cat_id = parts[1]
category = next((c for c in config.categories if str(c.id) == str(cat_id)), None)
if not category:
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
# Check if recruitment category with questions
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(self.bot, config, category)
else:
modal = TicketPriorityModal(self.bot, config, category)
# Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await inter.response.send_modal(modal)
cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None)
await open_ticket_for_category(self.bot, inter, config, cat_id)
except Exception as e:
kuby_logger.error(f"[TicketSystem] cat| routing error: {e}", exc_info=True)
kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True)
if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
@ -3225,56 +3352,83 @@ class TicketCommands(commands.Cog):
try:
parts = str(custom_id).split("_")
cat_id = "_".join(parts[1:-1]) if len(parts) >= 3 else (parts[1] if len(parts) > 1 else None)
category = next((c for c in config.categories if str(c.id) == str(cat_id)), None)
if not category:
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(self.bot, config, category)
else:
modal = TicketPriorityModal(self.bot, config, category)
# Sécurité anti-crash : Limite stricte de Discord à 45 caractères pour le titre
if hasattr(modal, "title") and modal.title:
modal.title = str(modal.title)[:45]
await inter.response.send_modal(modal)
await open_ticket_for_category(self.bot, inter, config, cat_id)
except Exception as e:
kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True)
kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True)
if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
elif custom_id == "ticket_create":
# Construction du layout de sélection Premium V2
category_items = [
disnake.ui.TextDisplay(
content="# 📂 Sélectionner une Catégorie - Omega Kube\n\n"
"Veuillez choisir la catégorie qui correspond au motif de votre demande pour ouvrir un ticket."
# Debug stockage/cache (pour diagnostiquer "catégorie non trouvée")
try:
kuby_logger.info(f"[TicketSystem][ticket_create] guild.id={getattr(inter.guild,'id',None)} guild_id={getattr(inter,'guild_id',None)}")
except Exception:
pass
self.storage.invalidate_cache(inter.guild.id)
config = self.storage.load_config(inter.guild.id)
try:
cats = getattr(config, "categories", None) or []
# Affiche aussi des infos pour diagnostiquer le storage
storage_obj = self.storage
config_path = getattr(storage_obj, "config_path", None)
kuby_logger.info(
f"[TicketSystem][ticket_create] storage.config_path={config_path} "
f"guild_id={inter.guild.id} "
f"categories_len={len(cats)} categories_ids={[getattr(c,'id',None) for c in cats[:5]]}"
)
]
# Génération dynamique des sections avec Emojis et Descriptions configurées
for cat in config.categories[:5]:
cat_emoji = getattr(cat, "emoji", "") or ""
cat_desc = getattr(cat, "description", "") or getattr(cat, "desc", "") or ""
category_items.append(disnake.ui.Separator(divider=True))
category_items.append(
disnake.ui.Section(
f"### {cat_emoji} {cat.name}\n{cat_desc}" if cat_desc else f"### {cat_emoji} {cat.name}",
accessory=disnake.ui.Button(
label="Choisir",
style=disnake.ButtonStyle.blurple,
custom_id=f"cat_{cat.id}_select"
)
except Exception:
pass
if not config.categories:
# Au cas où cache désynchronisé => invalider aussi avec guild_id
try:
self.storage.invalidate_cache(inter.guild_id)
config = self.storage.load_config(inter.guild_id)
cats = getattr(config, "categories", None) or []
kuby_logger.info(f"[TicketSystem][ticket_create] (after invalidate guild_id) categories_len={len(cats)}")
except Exception:
pass
if not config.categories:
await inter.response.send_message(
"Aucune catégorie configurée (data/tickets/config.json semble vide pour cette guild). "
"Allez dans la configuration et ajoutez au moins 1 catégorie.",
ephemeral=True
)
return
components = build_category_selection_components(config)
try:
await inter.response.send_message(
components=components,
flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True
)
await inter.response.send_message(
components=[disnake.ui.Container(*category_items)],
ephemeral=True
)
except Exception as e:
kuby_logger.error(f"[TicketSystem] ticket_create UI error: {e}", exc_info=True)
if inter.response.is_done():
await inter.followup.send(
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, config),
ephemeral=True
)
else:
await inter.response.send_message(
"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, config),
ephemeral=True
)
elif custom_id == "ticket_category_select":
self.storage.invalidate_cache(inter.guild.id)
config = self.storage.load_config(inter.guild.id)
cat_id = inter.values[0] if inter.values else None
if not cat_id:
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
return
await open_ticket_for_category(self.bot, inter, config, cat_id)
elif custom_id == "ticket_my_tickets":
# Afficher les tickets de l'utilisateur
user_tickets = self.storage.get_user_tickets(inter.guild.id, inter.user.id)
@ -3341,7 +3495,16 @@ class TicketCommands(commands.Cog):
return
# Vérifier que c'est du staff
if not is_staff(inter, config, category):
# Règle voulue:
# - Staff: toujours autorisé
# - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN)
# Ici on impose strictement staff (le bouton est staff dans la UI),
# et on protège aussi contre un éventuel usage côté user.
if (
not is_staff(inter, config, category) and not (
inter.user.id == ticket.user_id and ticket.status == TicketStatus.OPEN
)
):
await inter.response.send_message("Réservé au staff.", ephemeral=True)
return
@ -3417,9 +3580,13 @@ class TicketCommands(commands.Cog):
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
return
# Règle voulue:
# - Staff: toujours autorisé
# - Joueur: uniquement si le ticket n'est pas encore pris en charge (OPEN)
can_close = (
inter.user.id == ticket.user_id or
is_staff(inter, config, config.get_category(ticket.category_id))
is_staff(inter, config, config.get_category(ticket.category_id)) or (
inter.user.id == ticket.user_id and ticket.status == TicketStatus.OPEN
)
)
if not can_close:
await inter.response.send_message("Vous ne pouvez pas fermer ce ticket.", ephemeral=True)
@ -3568,33 +3735,15 @@ class TicketCommands(commands.Cog):
return
elif custom_id.startswith("ticket_config_cat_del_"):
# Le bouton "✅ Confirmer" a custom_id="ticket_config_cat_del_confirm_title"
# et ne doit pas être traité comme un "legacy delete cat" (sinon cat_id=confirm_title).
# Ne pas traiter ticket_config_cat_del_confirm_title ici :
# la suppression est déjà gérée par DeleteCategoryConfirmationView.confirm_callback.
# Traiter à nouveau ici provoquait une double suppression (2 catégories supprimées).
if custom_id == "ticket_config_cat_del_confirm_title":
# Juste reconstruire l'UI parent à partir de l'état actuel.
config = self.storage.load_config(inter.guild.id)
if not config:
await inter.response.send_message("Erreur: config introuvable.", ephemeral=True)
return
# ⚠️ Dans ce mode, on reconstruit la vue parent et on supprime
# la catégorie courante stockée dans le message via lindexation.
# Ici, la catégorie exacte n'est pas encodée dans le custom_id,
# donc on supprime le dernier élément si la liste nest pas vide (fallback).
# (Meilleur: corriger lencodage en index au moment de créer le bouton.)
if not config.categories:
await inter.response.send_message("Aucune catégorie à supprimer.", ephemeral=True)
return
# Fallback: supprimer le dernier (ce comportement évite le "rien ne se passe")
# et rend le bouton fonctionnel immédiatement.
category = config.categories[-1]
if category in config.categories:
config.categories.remove(category)
self.storage.save_config(inter.guild.id, config)
# Rebuild UI
view = CategoryManagerView(self.bot, self.storage, config)
await inter.response.edit_message(components=view.get_components_v2())
if config and config.categories is not None:
view = CategoryManagerView(self.bot, self.storage, config)
await inter.response.edit_message(components=view.get_components_v2())
return
# Recharger config pour éviter les mismatch (panel vs état storage)
@ -3803,15 +3952,18 @@ class TicketCommands(commands.Cog):
await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True)
# === COMMANDES ===
@commands.slash_command(name="ticket", description="Ouvre le panel des tickets")
@check_premium_tier()
async def ticket_main(self, inter: disnake.ApplicationCommandInteraction):
"""Ouvre le panel des tickets"""
config = self.storage.load_config(inter.guild.id)
view = TicketPanelView(self.bot, config, self.storage)
await inter.response.send_message(components=view.get_components_v2(), ephemeral=True)
@commands.slash_command(name="ticketpanel", description="Envoie le panel de création de tickets dans un canal")
@check_premium_tier()
@commands.has_permissions(administrator=True)
async def ticket_panel(self, inter: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel = None):
"""Envoie le panel de création de tickets dans un canal"""
@ -3826,11 +3978,16 @@ class TicketCommands(commands.Cog):
target = channel or inter.channel
view = TicketPanelView(self.bot, config, self.storage)
await target.send(components=view.get_components_v2())
panel_message = await target.send(components=view.get_components_v2())
config.panel_channel_id = target.id
config.panel_message_id = panel_message.id
self.storage.save_config(inter.guild.id, config)
await inter.edit_original_response(content=f"Panel envoyé dans {target.mention}")
@commands.slash_command(name="ticketconfig", description="Ouvre le panel de configuration complet du système de tickets")
@check_premium_tier()
async def ticket_config(self, inter: disnake.ApplicationCommandInteraction):
"""Ouvre le panel de configuration complet du système de tickets"""
await inter.response.defer(ephemeral=True)

View file

@ -301,6 +301,7 @@ class GuildTicketConfig:
claim_enabled: Whether claiming is enabled by default
survey_enabled: Whether surveys are enabled by default
panel_channel_id: Channel for the ticket panel
panel_message_id: Message ID of the ticket panel (for auto-refresh)
welcome_enabled: Whether welcome messages are shown
priority_enabled: Whether priority selection is enabled
"""
@ -322,6 +323,7 @@ class GuildTicketConfig:
claim_enabled: bool = True
survey_enabled: bool = True
panel_channel_id: Optional[int] = None
panel_message_id: Optional[int] = None
welcome_enabled: bool = True
priority_enabled: bool = True
@ -346,6 +348,7 @@ class GuildTicketConfig:
"claim_enabled": self.claim_enabled,
"survey_enabled": self.survey_enabled,
"panel_channel_id": self.panel_channel_id,
"panel_message_id": self.panel_message_id,
"welcome_enabled": self.welcome_enabled,
"priority_enabled": self.priority_enabled
}
@ -372,6 +375,7 @@ class GuildTicketConfig:
claim_enabled=data.get("claim_enabled", True),
survey_enabled=data.get("survey_enabled", True),
panel_channel_id=data.get("panel_channel_id"),
panel_message_id=data.get("panel_message_id"),
welcome_enabled=data.get("welcome_enabled", True),
priority_enabled=data.get("priority_enabled", True)
)

View file

@ -9,6 +9,7 @@ from typing import Optional
from src.logger import kuby_logger
import time
from datetime import datetime, timezone
from utils.premium import check_premium_tier
# Dictionnaire global pour suivre les renommages de salons
# Format: {channel_id: [timestamp1, timestamp2, ...]}
@ -1586,6 +1587,7 @@ class Ticket(commands.Cog):
self.bot.add_view(ClosedTicketView())
@commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.")
@check_premium_tier()
async def ticket_whitelist(self, interaction: disnake.ApplicationCommandInteraction):
"""Envoie l'embed avec le bouton d'ouverture de ticket"""
if not is_staff_or_admin(interaction.user, interaction.guild):
@ -1601,6 +1603,7 @@ class Ticket(commands.Cog):
await interaction.response.send_message("✅ Embed des tickets envoyé.", ephemeral=True)
@commands.slash_command(name="ticket_whitelist_config", description="Configurer le système de tickets whitelist (Staff/Admin)")
@check_premium_tier()
async def ticket_whitelist_config(self, interaction: disnake.ApplicationCommandInteraction):
"""Interface de configuration des tickets en Containers V2"""
if not is_staff_or_admin(interaction.user, interaction.guild):

View file

@ -7,6 +7,7 @@ import os
import logging
import asyncio
from PIL import Image, ImageDraw, ImageFont
from utils.premium import check_premium_tier
kuby_logger = logging.getLogger("KubyBot")
@ -345,6 +346,7 @@ class Welcome(commands.Cog):
return os.path.basename(banner_path)
@commands.slash_command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière")
@check_premium_tier()
@commands.has_permissions(administrator=True)
async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None):
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)
@ -385,6 +387,7 @@ class Welcome(commands.Cog):
await interaction.followup.send(error_message, ephemeral=True)
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
@check_premium_tier()
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
user = user or interaction.author
await self._send_invitation_stats(interaction, user)

View file

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

View file

@ -12,18 +12,17 @@ app = Flask(__name__)
PROJECT_PATH = "/home/discord/Bot/Kuby"
WEBHOOK_SECRET = "Nois2"
PROCESS_NAME = "kuby"
# webhook de logs Discord
DISCORD_LOG_URL = "https://discord.com/api/webhooks/1482148910888652910/IA9CcOWtjGswbuxMaOu_6uaclv5zojZo4ttxtV6RYyZzJ9gW7BF7xp_Zv3oPIjYEuh8o"
# Chemin vers PM2
# Chemin absolu vers le binaire PM2 (évite l'erreur 127 command not found)
PM2_BINARY = "/home/discord/.nvm/versions/node/v24.15.0/bin/pm2"
# Bot Local Proxy (Relai vers le Cog de ton bot)
# Bot Local Proxy
BOT_LOCAL_URL = "http://127.0.0.1:5001/bot_event"
def send_discord_log(status, message, color, author=None, commit_msg=None):
embed = {
"title": f"🚀 Kuby V2 [Forgejo] : {status}",
"title": f"🚀 Kuby V2 : {status}",
"description": message,
"color": color,
"timestamp": datetime.now(timezone.utc).isoformat(),
@ -37,7 +36,7 @@ def send_discord_log(status, message, color, author=None, commit_msg=None):
payload = {"embeds": [embed]}
try:
requests.post(DISCORD_LOG_URL, json=payload, timeout=10)
requests.post(DISCORD_LOG_URL, json=payload, timeout=10) # ✅ Augmenté de 5s à 10s
except Exception as e:
print(f"[!] Erreur envoi Discord : {e}")
@ -59,15 +58,29 @@ def run_mep_logic(author, commit_msg, branch="main"):
os.chdir(target_path)
# 1. GIT (Va maintenant pull depuis ton Forgejo auto-hébergé)
# 1. GIT
subprocess.run(["git", "fetch", "origin"], check=True)
subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True)
# 2. RSYNC (Fallback)
# 2. RSYNC (si on utilise le fallback de dépôt)
if use_rsync:
subprocess.run(["rsync", "-a", "--exclude=.git", "--exclude=venv", "--exclude=.venv", "--exclude=__pycache__", f"{target_path}/", f"{PROJECT_PATH}/"], check=True)
# 3. PM2 Restart
# ⚡ Migration automatique GitLab tracking
send_discord_log("MIGRATION", "Migration GitLab tracking en cours...", 16776960, author, commit_msg)
try:
subprocess.run(
["python3", os.path.join(target_path, "scripts", "migrate_gitlab_tracking.py")],
cwd=target_path,
check=True,
capture_output=True,
text=True
)
send_discord_log("MIGRATION", "✅ Migration GitLab tracking terminée.", 3066993, author, commit_msg)
except subprocess.CalledProcessError as e:
send_discord_log("ERREUR MIGRATION", f"❌ Migration GitLab échouée: {e.stderr}", 15158332, author, commit_msg)
# 3. PM2 (Appelé directement via son chemin absolu pour corriger l'erreur 127)
subprocess.run(
[PM2_BINARY, "restart", PROCESS_NAME],
check=True,
@ -76,24 +89,26 @@ def run_mep_logic(author, commit_msg, branch="main"):
)
duration = (datetime.now() - start_time).total_seconds()
msg = f"✅ **Déploiement réussi en {duration:.2f}s**\nLe bot est à jour sur Forgejo et redémarré."
msg = f"✅ **Déploiement réussi en {duration:.2f}s**\nLe bot est à jour et redémarré."
send_discord_log("VALIDÉ", msg, 3066993, author, commit_msg)
except subprocess.CalledProcessError as e:
error_stderr = e.stderr if e.stderr else "Pas de log d'erreur disponible."
# Capture spécifique si une commande système (comme Git ou PM2) crash
error_stderr = e.stderr if e.stderr else "Pas de log d'erreur disponible (stderr vide)."
error_msg = f"❌ **ERREUR de commande (Status {e.returncode})**\nCommande : `{ ' '.join(e.cmd) }`\n\n**Détails :**\n```\n{error_stderr}\n```"
send_discord_log("ÉCHEC", error_msg, 15158332, author, commit_msg)
except Exception as e:
# Capture globale pour le reste du script Python
error_msg = f"❌ **ERREUR de déploiement**\n```python\n{str(e)}\n```"
send_discord_log("ÉCHEC", error_msg, 15158332, author, commit_msg)
@app.route('/deploy', methods=['POST'])
def deploy():
# ⚡ MODIFICATION : Forgejo envoie son token secret dans 'X-Gitea-Token'
if request.headers.get('X-Gitea-Token') != WEBHOOK_SECRET:
# Sécurité
if request.headers.get('X-Gitlab-Token') != WEBHOOK_SECRET:
abort(403)
# Extraction des infos (La structure des commits est identique à GitLab)
# Extraction des infos GitLab
data = request.json
author = "Inconnu"
commit_msg = "Pas de message"
@ -106,32 +121,34 @@ def deploy():
ref = data.get('ref', 'refs/heads/main')
branch = ref.split('/')[-1] if ref else 'main'
# Threading inchangé pour répondre instantanément à Forgejo
# Threading pour répondre vite à GitLab
threading.Thread(target=run_mep_logic, args=(author, commit_msg, branch)).start()
return {"status": "accepted"}, 202
# Nouvelle route renommée pour la clarté, accepte aussi l'ancienne au cas où
@app.route('/forgejo_webhook', methods=['POST'])
@app.route('/gitlab_webhook', methods=['POST'])
def forgejo_webhook():
# ⚡ MODIFICATION : Sécurité via l'en-tête Forgejo
received_token = request.headers.get('X-Gitea-Token')
@app.route('/gitlab_webhook', methods=['POST'])
def gitlab_webhook():
# Protection par Token uniquement (anti-crawler géré par le secret)
received_token = request.headers.get('X-Gitlab-Token')
print(f"[DEBUG] Webhook d'événements reçu. Token reçu: '{received_token}'")
# Log de debug pour voir ce que GitLab envoie vs ce qu'on attend
print(f"[DEBUG] Webhook reçu. Token attendu: '{WEBHOOK_SECRET}', Token reçu: '{received_token}'")
if received_token != WEBHOOK_SECRET:
abort(403)
abort(403) # Changé en 403 pour différencier une route non trouvée d'un accès refusé
# Relai direct de la payload de l'événement (tickets, commentaires) vers ton bot local au port 5001
# Transférer la payload au bot Discord localement sur le port 5001
payload = request.json
try:
# On tente de relayer au bot discord. Timeout légèrement augmenté pour plus de stabilité.
# On ne passe plus de token interne pour simplifier comme demandé.
requests.post(BOT_LOCAL_URL, json=payload, timeout=5)
except Exception as e:
print(f"[!] Erreur de relais vers le bot Discord : {e}")
# On renvoie 200 à GitLab quand même car GitLab n'a pas à savoir l'état interne
return {"status": "received"}, 200
if __name__ == '__main__':
print(f"[*] Webhook Forgejo opérationnel sur le port 5000. Prêt pour les MEP !")
print(f"[*] Webhook opérationnel. En attente de push ou d'events...")
app.run(host='0.0.0.0', port=5000)

View file

@ -52,7 +52,7 @@ class KubyLogger:
# GitLab handler for automated error reporting
try:
from utils.forgejo_client import gitlab_client
from utils.gitlab_client import gitlab_client
class GitLabHandler(logging.Handler):
def __init__(self, client):
super().__init__()

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)

View file

@ -1,173 +0,0 @@
import aiohttp
import os
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional, List
# Logger interne mis à jour pour Forgejo
forgejo_internal_logger = logging.getLogger("ForgejoClient")
forgejo_internal_logger.setLevel(logging.INFO)
# Alias de compatibilité pour les anciens imports du bot
gitlab_internal_logger = forgejo_internal_logger
class ForgejoClient:
# MAP REEL DES LABELS (Pêchés directement depuis API Forgejo)
LABEL_MAPPING = {
"bug": 10,
"suggestion": 11, # Redirige "suggestion" vers "enhancement"
"enhancement": 11, # ID de ton étiquette bleue
"manual-report": 15,
"manual-suggestion": 16
}
def __init__(self):
self.token = os.getenv("FORGEJO_TOKEN")
self.owner = os.getenv("FORGEJO_OWNER") # Exemple: "Gameur"
self.repo = os.getenv("FORGEJO_REPO") # Exemple: "kuby"
self.assignee = os.getenv("FORGEJO_ASSIGNEE") # Pseudo string (optionnel)
# URL de base automatique via ton Tailscale.
# FIX DE SECURITE : On force le protocole HTTP brut pour éviter l'erreur "ssl:default" sur le port 80
configured_url = os.getenv("FORGEJO_URL", "http://omegakubeserv.tail951d2f.ts.net").strip().rstrip("/")
if configured_url.startswith("https://"):
configured_url = configured_url.replace("https://", "http://", 1)
self.base_url = configured_url
# Construction de l'URL de l'API v1 standard de Forgejo/Gitea
if "api/v1" not in self.base_url:
self.api_url = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues"
else:
self.api_url = f"{self.base_url}/repos/{self.owner}/{self.repo}/issues"
async def create_issue(self, title: str, description: str, labels: Optional[List[str]] = None) -> Optional[dict]:
"""
Crée un ticket sur Forgejo avec les bons IDs numériques de labels.
"""
if not self.token or not self.owner or not self.repo:
if not hasattr(self, '_config_warned'):
forgejo_internal_logger.warning("⚠️ L'intégration Forgejo n'est pas configurée (token/owner/repo manquants).")
self._config_warned = True
return None
headers = {
"Authorization": f"token {self.token}",
"Content-Type": "application/json"
}
# CONVERSION DES LABELS : On transforme les chaînes ("bug") en entiers (10) attendus par l'API Go
forgejo_label_ids = []
if labels:
for label in labels:
label_id = self.LABEL_MAPPING.get(label.lower())
if label_id:
forgejo_label_ids.append(label_id)
# Payload au format Forgejo strict
payload = {
"title": title,
"body": description,
"labels": forgejo_label_ids # Reçoit désormais un tableau d'int64 (ex: [10])
}
if self.assignee:
payload["assignees"] = [self.assignee]
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
try:
async with session.post(self.api_url, headers=headers, json=payload) as response:
if response.status == 201:
result = await response.json()
forgejo_internal_logger.info(f"✅ Forgejo issue créée : {result.get('html_url')}")
return result
else:
error_text = await response.text()
forgejo_internal_logger.error(f"❌ Échec de la création : {response.status} - {error_text}")
return None
except Exception as e:
forgejo_internal_logger.error(f"❌ Erreur de connexion à l'API Forgejo : {e}")
return None
async def find_issue_by_title(self, title: str, state: str = "open") -> Optional[dict]:
"""
Recherche un ticket par son titre exact et son état (open/closed).
"""
if not self.token or not self.owner or not self.repo:
return None
headers = {"Authorization": f"token {self.token}"}
forgejo_state = "open" if state == "opened" else state
params = {
"q": title,
"state": forgejo_state
}
async with aiohttp.ClientSession() as session:
try:
async with session.get(self.api_url, headers=headers, params=params) as response:
if response.status == 200:
issues = await response.json()
for issue in issues:
if issue.get("title") == title:
return issue
return None
else:
return None
except Exception as e:
forgejo_internal_logger.error(f"❌ Erreur lors de la recherche Forgejo : {e}")
return None
async def get_closed_issues(self) -> List[dict]:
"""
Récupère les tickets récemment fermés (depuis 24h).
"""
if not self.token or not self.owner or not self.repo:
return []
headers = {"Authorization": f"token {self.token}"}
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
params = {
"state": "closed",
"since": yesterday
}
async with aiohttp.ClientSession() as session:
try:
async with session.get(self.api_url, headers=headers, params=params) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
forgejo_internal_logger.error(f"❌ Erreur récupération tickets fermés : {response.status} - {error_text}")
return []
except Exception as e:
forgejo_internal_logger.error(f"❌ Erreur connexion API Forgejo : {e}")
return []
async def get_issue(self, issue_number: int) -> Optional[dict]:
"""
Récupère un ticket spécifique via son numéro (ID interne).
"""
if not self.token or not self.owner or not self.repo:
return None
headers = {"Authorization": f"token {self.token}"}
url = f"{self.api_url}/{issue_number}"
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, headers=headers) as response:
if response.status == 200:
return await response.json()
else:
return None
except Exception as e:
forgejo_internal_logger.error(f"❌ Erreur connexion API Forgejo : {e}")
return None
# --- SÉCURITÉ ET ALIAS DE COMPATIBILITÉ ---
GitLabClient = ForgejoClient
gitlab_client = ForgejoClient() # Singleton conservé sous l'ancien nom pour le reste du bot

View file

@ -1,16 +1,143 @@
"""Compatibilité rétroactive pour les anciens imports du bot.
import aiohttp
import os
import logging
from datetime import datetime, timedelta
from typing import Optional, List
Certaines commandes ou anciens modules peuvent encore importer
`utils.gitlab_client`. Ce fichier sert de pont vers le client Forgejo
actuel sans casser le chargement du bot.
"""
# Use a separate logger to avoid infinite loops if GitLab reporting fails
gitlab_internal_logger = logging.getLogger("GitLabClient")
gitlab_internal_logger.setLevel(logging.INFO)
# We don't add handlers here, it will just log to console through hierarchy or be silent if needed
from utils.forgejo_client import ForgejoClient, forgejo_internal_logger, gitlab_internal_logger
class GitLabClient:
def __init__(self):
self.token = os.getenv("GITLAB_TOKEN")
self.project_id = os.getenv("GITLAB_PROJECT_ID")
self.assignee_id = os.getenv("GITLAB_ASSIGNEE_ID")
self.base_url = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/")
# Ensure base_url only contains the schema and domain for the API endpoint
if "api/v4" not in self.base_url:
parts = self.base_url.split("/")
api_base = "/".join(parts[:3]) # Gets https://gitlab.com
self.api_url = f"{api_base}/api/v4/projects/{self.project_id}/issues"
else:
self.api_url = f"{self.base_url}/projects/{self.project_id}/issues"
# Nom conservé pour ne pas casser les imports existants
# (les anciens cogs utilisent souvent `gitlab_client`)
gitlab_client = ForgejoClient()
async def create_issue(self, title: str, description: str, labels: Optional[List[str]] = None) -> Optional[dict]:
"""
Creates an issue on GitLab.
"""
if not self.token or not self.project_id:
# Use a print or a lower level log to avoid triggering the GitLab logger itself
# which could cause an infinite loop if this were an ERROR log.
if not hasattr(self, '_config_warned'):
print("⚠️ GitLab integration is not configured. GitLab issues will not be created.")
self._config_warned = True
return None
# Exposition explicite du logger pour compatibilité
# pylint: disable=invalid-name
gitlab_internal_logger = gitlab_internal_logger
headers = {
"PRIVATE-TOKEN": self.token
}
data = {
"title": title,
"description": description,
"labels": ",".join(labels) if labels else ""
}
if self.assignee_id:
data["assignee_ids"] = [self.assignee_id]
async with aiohttp.ClientSession() as session:
try:
async with session.post(self.api_url, headers=headers, data=data) as response:
if response.status == 201:
result = await response.json()
gitlab_internal_logger.info(f"✅ GitLab issue created: {result.get('web_url')}")
return result
else:
error_text = await response.text()
gitlab_internal_logger.error(f"❌ Failed to create GitLab issue: {response.status} - {error_text}")
return None
except Exception as e:
gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}")
return None
async def find_issue_by_title(self, title: str, state: str = "opened") -> Optional[dict]:
"""
Searches for an issue by its exact title and state.
"""
if not self.token or not self.project_id:
return None
headers = {"PRIVATE-TOKEN": self.token}
params = {
"search": title,
"state": state
}
async with aiohttp.ClientSession() as session:
try:
# search param is fuzzy on GitLab, so we must double-check for exact match
async with session.get(self.api_url, headers=headers, params=params) as response:
if response.status == 200:
issues = await response.json()
for issue in issues:
if issue.get("title") == title:
return issue
return None
else:
return None
except Exception as e:
gitlab_internal_logger.error(f"❌ Error searching GitLab issues: {e}")
return None
async def get_closed_issues(self) -> List[dict]:
"""
Fetches recently closed issues.
"""
if not self.token or not self.project_id:
return []
headers = {"PRIVATE-TOKEN": self.token}
params = {
"state": "closed",
"updated_after": (datetime.now() - timedelta(days=1)).isoformat()
}
async with aiohttp.ClientSession() as session:
try:
async with session.get(self.api_url, headers=headers, params=params) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
gitlab_internal_logger.error(f"❌ Failed to fetch closed issues: {response.status} - {error_text}")
return []
except Exception as e:
gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}")
return []
async def get_issue(self, issue_iid: int) -> Optional[dict]:
"""
Fetches a specific issue by its internal ID (IID).
"""
if not self.token or not self.project_id:
return None
headers = {"PRIVATE-TOKEN": self.token}
url = f"{self.base_url}/api/v4/projects/{self.project_id}/issues/{issue_iid}"
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, headers=headers) as response:
if response.status == 200:
return await response.json()
else:
return None
except Exception as e:
gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}")
return None
# Instance unique
gitlab_client = GitLabClient()

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
]
}