Merge branch 'dev' into 'main'

Dev

See merge request Omega_Kube/kuby!25
This commit is contained in:
Gameur 2026-05-31 12:25:57 +02:00
commit a1f06566d4
26 changed files with 1388 additions and 319 deletions

BIN
EXEMPLE_ARRIVER.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

319
EXPLICATION_NON_DEV.md Normal file
View file

@ -0,0 +1,319 @@
# 📜 **Guide Simplifié : Le Système de Secours de Kuby**
*(Inspiré de la série **Person of Interest** La Machine)*
---
## 🎯 **Pourquoi ce système existe ?**
Imagine que **Kuby est comme *La Machine*** dans *Person of Interest* :
- **La Machine** surveille tout le monde et **protège les gens en danger** (comme Kuby qui protège tes serveurs Discord).
- Si un **serveur de la Machine est attaqué ou tombe en panne**, un autre **prend immédiatement le relais** pour que **personne ne soit en danger**.
- Dans notre cas, **Kuby est la Machine**, et **tes serveurs Discord sont les personnes protégées**.
**Le problème** : Si le serveur principal de Kuby tombe en panne, tous les serveurs affiliés à Omega Kube **ne sont plus protégés**.
**La solution** : On crée un **réseau de "nœuds de secours"** (comme les serveurs cachés de la Machine) qui **prennent le relais automatiquement** si le principal est hors ligne.
---
---
## 🧠 **Comment ça marche ? (Version ultra-simple)**
### 1**Le Cœur de Kuby** *(comme le cœur de la Machine)*
- Cest un **fichier partagé** (comme un tableau de bord) qui contient **létat de santé de Kuby** :
- Est-ce quil est vivant ?
- Quel nœud est actif ?
- Y a-t-il une maintenance en cours ?
- **Où est-il ?** Sur **GitHub** (un service gratuit et sécurisé, comme un cloud).
---
### 2**Les Nœuds** *(comme les serveurs de la Machine)*
- **Nœud Principal** = Le serveur actuel de Kuby (ton VPS). Cest **Harold Finch** (le cerveau principal).
- **Nœuds de Secours** = Les **PC de 2-3 membres de confiance** (comme **John Reese, Root, ou Bear** dans POI).
- Ces PC **surveillent Kuby** en arrière-plan.
- Si le nœud principal tombe, **un nœud de secours prend le relais automatiquement**.
---
### 3**Le Signal de Vie (Heartbeat)** *(comme les signaux de la Machine)*
- Toutes les **5 minutes**, le nœud actif (celui qui fait tourner Kuby) envoie un **signal** :
→ *« Je suis toujours vivant et en bonne santé ! »*
- Si **aucun signal nest reçu pendant 10 minutes**, cest quil y a un **problème** (comme quand la Machine est attaquée dans POI).
---
### 4**La Bascule Automatique (Failover)** *(comme quand la Machine bascule sur un autre serveur)*
- Quand un nœud de secours **ne reçoit plus de signal**, il se dit :
*« La Machine principale est en danger, **je prends le relais !** »*
- Il **active sa copie de Kuby** et **prévient tout le monde** (comme quand Reese active un serveur de secours dans POI).
---
### 5**Le Retour à la Normale** *(comme quand la Machine se répare)*
- Quand le **nœud principal revient en ligne**, il **reprend son rôle** (comme quand Finch répare la Machine).
- Le nœud de secours **séteint automatiquement** (plus besoin de lui).
---
---
## 🎭 **Exemple Concret avec *Person of Interest***
| **Dans *Person of Interest*** | **Dans notre système** | **Ce qui se passe** |
|--------------------------------|-----------------------|---------------------|
| **La Machine** | **Kuby** | Le bot qui protège tes serveurs Discord. |
| **Serveurs de la Machine** | **Nœuds (ton VPS + PCs des membres)** | Plusieurs endroits où Kuby peut tourner. |
| **Harold Finch** | **Toi (Omega Kube)** | Tu gères le système et choisis les nœuds de confiance. |
| **John Reese** | **Un membre de confiance (ex: Alice)** | Elle héberge un nœud de secours sur son PC. |
| **Signal de la Machine** | **Heartbeat (toutes les 5 min)** | Preuve que Kuby est toujours vivant. |
| **Attaque sur un serveur** | **Crash du VPS** | Le nœud dAlice prend le relais automatiquement. |
| **La Machine se répare** | **Ton VPS redémarre** | Le nœud principal reprend le contrôle. |
| **Les "Numéros" (personnes protégées)** | **Propriétaires de serveurs affiliés** | Ils reçoivent un message clair en cas de problème. |
---
---
## 👥 **Rôle de Chaque Personne**
### 👑 **Toi (Omega Kube) = Harold Finch**
*(Le génie qui a créé la Machine et la gère.)*
- **Tu choisis les membres de confiance** (comme Finch choisit ses alliés).
- **Tu créés le fichier partagé** (le cœur de Kuby).
- **Tu tests le système** avant de le lancer.
- **Tu es le seul à avoir accès à tout** (comme Finch avec la Machine).
---
### 💻 **Membres Hébergeurs = John Reese, Root, ou Bear**
*(Les alliés de Finch qui aident à maintenir la Machine.)*
- **Ils installent un petit programme** sur leur PC (comme Reese qui installe des terminaux pour la Machine).
- **Leur PC surveille Kuby** en arrière-plan (comme un antivirus).
- **Si Kuby tombe**, leur PC **lance automatiquement une copie de secours** (sans rien faire manuellement).
- **Quand le serveur principal revient**, leur PC **arrête la copie de secours** (tout seul).
**Ce quils doivent faire** :
1. **Installer Python** (1 seule fois, comme installer un logiciel).
2. **Lancer un programme** (`monitor.py`) en arrière-plan.
3. **Ne pas éteindre leur PC trop souvent** (ou prévenir si besoin).
4. **Garder une connexion Internet stable** (comme pour regarder un film en streaming).
**Ce quils ne doivent PAS faire** :
- **Modifier le code** (risque de casser le système).
- **Éteindre leur PC sans prévenir** (si cest un nœud critique).
- **Partager leur token Discord** (cest comme partager un mot de passe).
---
### 📢 **Propriétaires de Serveurs Affiliés = Les "Numéros"**
*(Les personnes protégées par la Machine.)*
- **Ils reçoivent un message clair** en cas de problème (comme quand la Machine prévient Reese dun numéro en danger).
- **Exemple de message quils reçoivent** :
```
⚠️ **Kuby a rencontré un problème, mais ne tinquiète pas !**
Un nœud de secours a pris le relais.
✅ Le service continue de fonctionner normalement.
💬 Besoin daide ? Contacte @OmegaKube.
```
---
---
## 📋 **Ce que les Hébergeurs Doivent Faire (Étape par Étape)**
### **1⃣ Installation (1 seule fois)**
1. **Télécharger Python** (comme un logiciel classique) :
- Aller sur [python.org/downloads](https://www.python.org/downloads/).
- **Cocher "Add Python to PATH"** pendant linstallation (très important !).
- Cliquer sur **Install Now**.
2. **Télécharger les fichiers** que je tenverrai (un dossier zip).
3. **Ouvrir linvite de commandes** (ou terminal) :
- **Windows** : `Win + R` → taper `cmd` → Entrée.
- **Mac/Linux** : Ouvrir **Terminal**.
4. **Installer les outils nécessaires** (1 seule fois) :
```bash
pip install -r requirements.txt
```
*(Cest comme installer les pièces manquantes pour faire fonctionner Kuby.)*
---
### **2⃣ Configuration (1 seule fois)**
1. **Ouvrir le fichier `.env`** (avec le Bloc-notes ou VS Code).
2. **Remplir avec tes informations** (je te lenverrai déjà rempli, mais vérifie) :
```
NODE_ID=node_secours_alice
NODE_NAME=Kuby (PC de Alice)
```
3. **Enregistrer le fichier**.
---
### **3⃣ Lancement (À faire 1 fois, puis ça tourne tout seul)**
1. **Ouvrir linvite de commandes** dans le dossier où sont les fichiers.
2. **Lancer le programme de surveillance** :
```bash
python monitor.py
```
*(Cest comme lancer un antivirus : **ça tourne en arrière-plan sans que tu aies à y penser**.)*
3. **Laisser le programme ouvert** (ou le lancer en arrière-plan).
---
### **4⃣ Utilisation au Quotidien**
- **Rien à faire !** Le programme **travaille tout seul**.
- **Ne pas fermer la fenêtre** du terminal (ou utiliser un outil pour le lancer en arrière-plan).
- **Si tu éteins ton PC** :
- **Préviens-moi** avant (pour que je puisse trouver un remplaçant si besoin).
- **Plus il y a de nœuds**, moins cest grave si un PC est éteint.
---
---
## ⚠️ **Que se passe-t-il en cas de panne ?**
### **🔴 Scénario 1 : Ton VPS (nœud principal) crash**
*(Comme quand un serveur de la Machine est attaqué dans POI.)*
1. **Détection** : Après **10 minutes sans signal**, un nœud de secours (ex: PC dAlice) **détecte le problème**.
2. **Action** : Le PC dAlice **lance automatiquement Kuby** (comme Reese active un serveur de secours).
3. **Notification** :
- Un message est envoyé dans **le serveur Discord de Omega Kube** (canal `#🚨-urgent-kuby`).
- Un **message privé** est envoyé à tous les propriétaires de serveurs affiliés.
4. **Résultat** : **Personne ne remarque la panne** (tout continue de fonctionner).
---
### **🟢 Scénario 2 : Ton VPS revient en ligne**
*(Comme quand Finch répare la Machine.)*
1. **Détection** : Le nœud principal **voit quun nœud de secours a pris le relais**.
2. **Attente** : Il **attend 5 minutes** (pour éviter les allers-retours).
3. **Reprise** : Il **reprend le contrôle** et **arrête le nœud de secours**.
4. **Notification** :
```
✅ **Kuby est de retour sur le serveur principal !**
🎉 Merci au nœud de secours (PC dAlice) pour son aide.
```
---
### **🟡 Scénario 3 : Plusieurs PC de secours sont éteints**
*(Comme quand plusieurs serveurs de la Machine sont hors ligne.)*
- **Solution** : Avoir **au moins 2-3 nœuds de secours** (comme la Machine a plusieurs serveurs).
- Si **tous les nœuds de secours sont éteints**, je recevrai une alerte et **je devrai redémarrer manuellement le VPS**.
- **Cest pour ça quil en faut plusieurs** : plus il y a de nœuds, plus le système est **résistant**.
---
---
## 💡 **Questions Fréquentes (FAQ)**
### **❓ Est-ce que ça va ralentir mon PC ?**
➡️ **Non !**
- Kuby est un **bot Discord léger**.
- Il utilise **moins de ressources quun onglet Chrome ouvert** ou quun jeu mobile.
- Tu peux **continuer à utiliser ton PC normalement** (regarder des vidéos, jouer, etc.).
---
### **❓ Est-ce que je dois laisser mon PC allumé 24/7 ?**
➡️ **Idéalement oui**, mais :
- Si tu dois **éteindre ton PC** (pour la nuit, par exemple) :
- **Préviens-moi** avant (pour que je puisse morganiser).
- **Plus il y a de nœuds**, moins cest grave si un PC est éteint.
- Si tu es **souvent absent**, je peux te retirer de la liste et trouver quelquun dautre.
---
### **❓ Que faire si mon Internet tombe en panne ?**
➡️ **Rien**. Quand ton Internet reviendra, le programme **reprendra automatiquement** son travail.
---
### **❓ Est-ce que cest sécurisé ?**
➡️ **Oui, à 100% !**
- Chaque nœud a **son propre token Discord** (comme une clé unique).
- Les données sont **stockées sur GitHub** (un service **sécurisé et chiffré**).
- **Personne ne peut prendre le contrôle de ton PC** via ce programme.
- **Aucun accès** à tes fichiers personnels.
---
### **❓ Je veux arrêter dêtre un nœud de secours, comment faire ?**
➡️ **Contacte-moi (@OmegaKube)** et je te retirerai de la liste.
- **Aucun risque** : tu peux arrêter à tout moment.
- **Aucune obligation** : cest du bénévolat, merci pour ton aide !
---
### **❓ Que faire si je vois une erreur dans le terminal ?**
➡️ **Fais un capture décran** et envoie-la à @OmegaKube.
- Exemple derreur normale (pas grave) :
```
[ERREUR] Impossible de se connecter à GitHub (timeout). Réessai dans 5 min...
```
**Solution** : Attendre que ton Internet revienne.
---
### **❓ Est-ce que je peux héberger Kuby sur mon Raspberry Pi ?**
➡️ **Oui !** (Mais ce nest pas obligatoire.)
- Un **PC classique** suffit largement.
- Si tu as un **Raspberry Pi**, cest encore mieux (il consomme moins délectricité).
---
---
## 🎯 **Pourquoi cest important ?**
### **Pour toi (hébergeur)** :
**Tu aides la communauté Omega Kube** sans effort.
**Tu apprends** comment fonctionnent les systèmes décentralisés (comme dans POI !).
**Tu deviens un maillon essentiel** du projet.
**Aucun coût** pour toi (juste un peu délectricité).
### **Pour les propriétaires de serveurs** :
**Zéro interruption** de service, même en cas de panne.
**Transparence totale** : ils sont toujours prévenus.
**Fiabilité accrue** : le système est **plus résistant quun seul serveur**.
---
---
## 📞 **Qui contacter en cas de problème ?**
| **Problème** | **Contact** | **Temps de réponse** |
|--------------|-------------|----------------------|
| Question technique | @OmegaKube (DM Discord) | < 24h |
| Problème avec ton nœud | @OmegaKube (DM Discord) | < 1h |
| **Urgent** (tous les nœuds down) | @OmegaKube (DM + email) | **Immédiat** |
---
---
## 🎬 **Résumé en 30 Secondes (Version *Person of Interest*)**
> *« Imagine que Kuby, cest la Machine. Ton VPS, cest son serveur principal. Mais comme dans *Person of Interest*, la Machine a des serveurs de secours cachés un peu partout.*
> *Toi, tu es comme Reese ou Finch : tu héberges un de ces serveurs sur ton PC.*
> *Si jamais la Machine principale tombe (ton VPS crash), ton PC devient le nouveau cerveau, et tout continue de fonctionner comme avant.*
> *Quand le serveur principal revient, il reprend sa place, et toi, tu nas rien eu à faire.*
> **Cest aussi simple que ça.** »
---
## 🔥 **Métaphore Finale**
| **Dans *Person of Interest*** | **Dans notre système** |
|--------------------------------|-----------------------|
| La Machine **ne dort jamais** | Kuby **ne sarrête jamais** (grâce aux nœuds de secours). |
| Finch **a toujours un plan B** | Toi, tu as **toujours un nœud de secours**. |
| Reese **protège les numéros** | Les nœuds **protègent les serveurs affiliés**. |
| La Machine **se répare toute seule** | Le système **bascule automatiquement**. |
---
**Merci daider à maintenir Kuby en vie !** 🚀
*Guide simplifié - Version 1.0 - Inspirée de *Person of Interest**
*Pour toute question, contacte @OmegaKube.*

490
Explication.md Normal file
View file

@ -0,0 +1,490 @@
# 🔧 Système Décentralisé de Secours pour Kuby - Documentation Technique
---
## 📌 Contexte
Kuby est un bot Discord critique pour les serveurs affiliés à Omega Kube. Pour éviter toute interruption de service en cas de crash du serveur principal, nous mettons en place un **système décentralisé de secours** où plusieurs **nœuds de confiance** (ordinateurs personnels de membres sélectionnés) peuvent héberger et lancer Kuby automatiquement.
**Objectif** : **Zéro temps d'arrêt**, même si le serveur principal est hors ligne.
---
## 🏗️ Architecture du Système
### **1. Composants Clés**
| Composant | Rôle | Emplacement |
|-----------|------|--------------|
| **Fichier de Statut Partagé** (`kuby_status.json`) | Stocke l'état du système (dernier heartbeat, nœud actif, etc.) | **GitHub Gist** (gratuit, accessible en lecture/écriture) |
| **Nœud Principal** | Instance principale de Kuby (sur ton serveur) | Ton VPS/serveur actuel |
| **Nœuds de Secours** | Instances de Kuby sur les PCs des membres de confiance | Ordinateurs personnels |
| **Script de Surveillance** (`monitor.py`) | Vérifie le heartbeat et déclenche la bascule | Exécuté par chaque nœud |
| **Script de Lancement** (`launch_kuby.py`) | Lance Kuby en mode actif/secours | Exécuté par chaque nœud |
---
### **2. Flux de Données**
```
┌─────────────────────────────────────────────────────────────────────────┐
│ GitHub Gist (kuby_status.json) │
│ { │
│ "last_heartbeat": "2024-10-15T14:00:00Z", │
│ "active_node": "node_principal", │
│ "active_node_ip": "123.45.67.89", │
│ "version": "1.2.0", │
│ "mode": "normal" // ou "secours" │
│ } │
└─────────────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Nœud Principal │ │ Nœud de Secours │
│ (Ton serveur) │ │ (PC d'un membre) │
│ │ │ │
│ 1. Exécute Kuby │ │ 1. Exécute monitor │
│ 2. Met à jour │ │ 2. Vérifie │
│ heartbeat/5min │ │ kuby_status.json│
│ 3. Si crash → │ │ 3. Si last_heartbeat│
│ ne fait rien │ │ > 10 min ET │
│ │ │ active_node != │
│ │ │ "moi" → │
│ │ │ Prend le relais │
└─────────────────────┘ └─────────────────────┘
```
---
### **3. Rôles des Fichiers**
#### **📄 `kuby_status.json` (Sur GitHub Gist)**
Stocke l'état global du système. **Tous les nœuds lisent et écrivent dans ce fichier.**
**Structure** :
```json
{
"last_heartbeat": "2024-10-15T14:00:00Z", // Dernière mise à jour par le nœud actif
"active_node": "node_principal", // ID du nœud actuellement actif
"active_node_name": "Kuby (Serveur Principal)", // Nom du nœud (pour les logs)
"active_node_discord_id": "123456789", // ID Discord du bot (pour éviter les conflits)
"version": "1.2.0", // Version de Kuby en cours
"mode": "normal", // "normal" ou "secours"
"emergency_message": null // Message d'urgence si panne totale
}
```
#### **🐍 `monitor.py` (À exécuter par chaque nœud)**
**Rôle** :
- Vérifie toutes les **5 minutes** si le nœud actif (`active_node`) a mis à jour `last_heartbeat`.
- Si **aucune mise à jour depuis 10+ minutes**, le nœud **prend le relais**.
**Logique** :
```python
while True:
status = fetch_status_from_gist()
last_heartbeat = datetime.fromisoformat(status["last_heartbeat"])
if (now - last_heartbeat).total_seconds() > 600: # 10 minutes
if status["active_node"] != MY_NODE_ID:
take_over(status) # Prend le relais
if status["active_node"] == MY_NODE_ID:
update_heartbeat() # Met à jour last_heartbeat
sleep(300) # Attend 5 minutes
```
#### **🚀 `launch_kuby.py` (Lance Kuby en mode actif/secours)**
**Rôle** :
- Lance le bot Kuby avec les **bonnes permissions** (mode normal ou secours).
- **Met à jour `active_node`** dans `kuby_status.json` pour indiquer qu'il est actif.
**Cas d'usage** :
- **Nœud principal** : Lance Kuby normalement.
- **Nœud de secours** : Lance Kuby **seulement si `active_node == MY_NODE_ID`**.
---
## 🔄 Mécanisme de Bascule (Failover)
### **1. Détection d'un Crash**
- Un **nœud de secours** détecte que `last_heartbeat` a **plus de 10 minutes**.
- Il vérifie que **`active_node` != son ID** (pour éviter les conflits).
- Il **met à jour `kuby_status.json`** :
```json
{
"last_heartbeat": "2024-10-15T14:10:00Z",
"active_node": "node_secours_1",
"active_node_name": "Kuby (PC de [Membre])",
"active_node_discord_id": "987654321",
"mode": "secours",
"emergency_message": "Le serveur principal est down. Ce nœud a pris le relais."
}
```
- Il **lance Kuby** en mode actif.
### **2. Notification aux Propriétaires**
Le nœud de secours envoie :
1. Un **message dans le serveur hub** (canal `#🚨-urgent-kuby`) :
```
🚨 **BASCULE AUTOMATIQUE ACTIVÉE** 🚨
⚠️ Le serveur principal de Kuby est hors ligne.
🛡️ **Nouveau nœud actif** : Kuby (PC de [Membre])
**Heure de bascule** : [HH:MM]
📌 **Action** : Aucun redémarrage manuel nécessaire.
```
2. Un **DM à tous les propriétaires** :
```
⚠️ **Kuby a basculé sur un nœud de secours**
Le serveur principal est temporairement indisponible.
🔄 **Nouveau nœud actif** : PC de [Membre]
💬 **Pas d'action requise** : Le service reste opérationnel.
```
### **3. Retour à la Normale**
- Le **nœud principal** revient en ligne.
- Il détecte que `active_node != "node_principal"`.
- Il **attend 5 minutes** (pour éviter les oscillations).
- Il **reprend le contrôle** en :
1. Mettant à jour `kuby_status.json` :
```json
{
"active_node": "node_principal",
"mode": "normal",
"emergency_message": null
}
```
2. **Arrêtant le nœud de secours** (via un message Discord ou un appel API).
3. Envoyant un **message de rétablissement** :
```
✅ **Kuby est de retour sur le serveur principal**
🎉 Merci au nœud de secours pour son aide !
```
---
## 🛠️ Implémentation Technique (Disnake)
### **1. Prérequis**
- **Python 3.8+**
- **Disnake** (déjà utilisé dans le projet)
- **Bibliothèques supplémentaires** :
```bash
pip install aiohttp python-dotenv
```
### **2. Fichier `.env` (Pour chaque nœud)**
```env
# Identifiant unique du nœud (ex: node_principal, node_secours_1, node_secours_2)
NODE_ID=node_secours_1
NODE_NAME=Kuby (PC de [Prénom])
NODE_DISCORD_BOT_TOKEN=ton_token_bot_discord
# GitHub Gist
GIST_ID=ton_id_gist
GITHUB_TOKEN=ton_token_github # Optionnel (pour éviter les rate limits)
# Discord
SERVEUR_HUB_ID=123456789
CANAL_URGENT_ID=987654321
# Délais (en secondes)
HEARTBEAT_INTERVAL=300 # 5 minutes
CRASH_THRESHOLD=600 # 10 minutes
```
### **3. Structure des Fichiers**
```
/kuby_decentralise
├── monitor.py # Surveillance du heartbeat
├── launch_kuby.py # Lancement de Kuby
├── status_manager.py # Gestion de kuby_status.json
├── config.py # Configuration commune
├── .env # Variables d'environnement
└── README.md # Instructions
```
---
### **4. Exemple de Code (Pseudocode)**
#### **📄 `status_manager.py`**
```python
import aiohttp
import os
from dotenv import load_dotenv
load_dotenv()
GIST_URL = f"https://gist.githubusercontent.com/{os.getenv('GITHUB_USER')}/{os.getenv('GIST_ID')}/raw/kuby_status.json"
GIST_API_URL = f"https://api.github.com/gists/{os.getenv('GIST_ID')}"
async def fetch_status():
async with aiohttp.ClientSession() as session:
async with session.get(GIST_URL) as response:
if response.status == 200:
return await response.json()
else:
raise Exception(f"Erreur {response.status} : Impossible de récupérer kuby_status.json")
async def update_status(new_status: dict):
headers = {"Authorization": f"token {os.getenv('GITHUB_TOKEN')}"} if os.getenv('GITHUB_TOKEN') else {}
data = {
"files": {
"kuby_status.json": {
"content": json.dumps(new_status, indent=2)
}
}
}
async with aiohttp.ClientSession() as session:
async with session.patch(GIST_API_URL, json=data, headers=headers) as response:
if response.status != 200:
raise Exception(f"Erreur {response.status} : Impossible de mettre à jour kuby_status.json")
return await response.json()
```
#### **📄 `monitor.py`**
```python
import asyncio
from datetime import datetime, timedelta
from status_manager import fetch_status, update_status
from launch_kuby import launch_kuby
import os
NODE_ID = os.getenv("NODE_ID")
NODE_NAME = os.getenv("NODE_NAME")
NODE_DISCORD_ID = os.getenv("NODE_DISCORD_BOT_TOKEN") # À adapter
async def take_over():
"""Prend le relais si le nœud actif est down."""
status = await fetch_status()
now = datetime.utcnow().isoformat() + "Z"
# Vérifier si on peut prendre le relais
last_heartbeat = datetime.fromisoformat(status["last_heartbeat"])
if (datetime.utcnow() - last_heartbeat) < timedelta(minutes=10):
return False # Pas besoin de prendre le relais
if status["active_node"] == NODE_ID:
return False # Déjà actif
# Mettre à jour le statut
status["last_heartbeat"] = now
status["active_node"] = NODE_ID
status["active_node_name"] = NODE_NAME
status["active_node_discord_id"] = NODE_DISCORD_ID
status["mode"] = "secours"
status["emergency_message"] = f"Bascule automatique : {NODE_NAME} a pris le relais."
await update_status(status)
return True
async def monitor():
while True:
try:
status = await fetch_status()
last_heartbeat = datetime.fromisoformat(status["last_heartbeat"])
now = datetime.utcnow()
# Si le nœud actif est down depuis 10+ min, prendre le relais
if (now - last_heartbeat) > timedelta(minutes=10) and status["active_node"] != NODE_ID:
if await take_over():
print(f"[{NODE_NAME}] Prend le relais !")
await launch_kuby() # Lance Kuby
# Si ce nœud est actif, mettre à jour le heartbeat
if status["active_node"] == NODE_ID:
status["last_heartbeat"] = now.isoformat() + "Z"
await update_status(status)
except Exception as e:
print(f"[ERREUR] {e}")
await asyncio.sleep(300) # Attend 5 minutes
if __name__ == "__main__":
asyncio.run(monitor())
```
---
## 🔐 Sécurité et Bonnes Pratiques
### **1. Gestion des Conflits**
- **Problème** : Deux nœuds de secours pourraient essayer de prendre le relais en même temps.
- **Solution** : Utiliser un **verrou optimiste** dans `kuby_status.json` :
```json
{
"lock": null, // ou l'ID du nœud qui tente de prendre le relais
"lock_timestamp": null
}
```
- Un nœud ne peut prendre le relais que si `lock` est `null` ou a **plus de 5 minutes**.
### **2. Authentification**
- **Problème** : Un malware pourrait modifier `kuby_status.json`.
- **Solution** :
- Utiliser un **token GitHub** pour les mises à jour.
- **Signer les requêtes** avec une clé secrète (optionnel).
### **3. Liste des Nœuds de Confiance**
Maintenir une **liste blanchie** dans `kuby_status.json` :
```json
{
"trusted_nodes": [
{"id": "node_principal", "name": "Kuby (Serveur)", "discord_id": "123456789"},
{"id": "node_secours_1", "name": "Kuby (PC de Alice)", "discord_id": "987654321"},
{"id": "node_secours_2", "name": "Kuby (PC de Bob)", "discord_id": "567891234"}
]
}
```
- Un nœud ne peut prendre le relais que s'il est dans cette liste.
---
## 📊 Exemple de Workflow Complet
### **Scénario 1 : Maintenance Planifiée**
1. **Toi** exécutes `!maintenance start 72h "Mise à jour majeure"` sur le **nœud principal**.✅
2. Le nœud principal met à jour `kuby_status.json` :
```json
{
"maintenance": {
"date": "2024-10-18T14:00:00Z",
"raison": "Mise à jour majeure"
}
}
```
3. Les nœuds de secours **détectent la maintenance** et n'interviennent pas.
### **Scénario 2 : Crash du Serveur Principal**
1. Le **nœud principal** crash → `last_heartbeat` n'est plus mis à jour.
2. Après **10 minutes**, un **nœud de secours** (ex: PC d'Alice) détecte le problème.
3. Alice's node **met à jour `kuby_status.json`** et lance Kuby.
4. Un **message est envoyé** dans `#🚨-urgent-kuby` et aux propriétaires.
5. Quand le nœud principal revient, il **reprend le contrôle** automatiquement.
### **Scénario 3 : Panne Totale (Tous les nœuds down)**
1. **Aucun nœud** ne met à jour `last_heartbeat`.
2. **UptimeRobot** (optionnel) détecte que le bot Discord ne répond plus.
3. UptimeRobot envoie un **webhook dans `#🚨-urgent-kuby`** :
```
🚨 **PANNE TOTALE DE KUBY** 🚨
Tous les nœuds sont hors ligne !
@OmegaKube **Action manuelle requise**.
```
---
## 🛑 Limitations et Solutions
| Limitation | Cause | Solution |
|------------|-------|----------|
| **Délai de bascule** (~10 min) | Temps de détection du crash | Réduire `CRASH_THRESHOLD` à 5 min (mais risque de fausses alertes). |
| **PC éteint** | Un nœud de secours est hors ligne | Avoir **au moins 3 nœuds de confiance**. |
| **GitHub down** | Impossible d'accéder à `kuby_status.json` | Utiliser un **mirror sur Google Drive** ou **IPFS**. |
| **Conflits de nœuds** | Deux nœuds prennent le relais en même temps | Implémenter un **verrou optimiste** (voir section Sécurité). |
| **Données non synchronisées** | Un nœud a des données différentes | Utiliser une **base de données partagée** (Firebase gratuit). |
---
## 📝 Étapes de Déploiement
### **1. Préparation**
- [ ] Créer un **GitHub Gist** avec le `kuby_status.json` initial.
- [ ] Sélectionner **2-3 membres de confiance** pour héberger des nœuds de secours.
- [ ] Leur fournir :
- Le code (`monitor.py`, `launch_kuby.py`, etc.).
- Un **token Discord** pour leur instance de Kuby (chaque nœud a son propre bot).
- Un **fichier `.env`** pré-rempli avec leurs identifiants.
### **2. Configuration des Nœuds**
Pour chaque membre :
1. Cloner le dépôt du projet Kuby.
2. Installer les dépendances (`pip install -r requirements.txt`).
3. Remplir leur `.env` avec leurs informations.
4. **Tester le nœud** :
```bash
python monitor.py
```
### **3. Test du Système**
1. **Simuler un crash** :
- Arrêter le nœud principal.
- Vérifier qu'un nœud de secours prend le relais après 10 min.
2. **Simuler un retour** :
- Redémarrer le nœud principal.
- Vérifier qu'il reprend le contrôle.
---
## 💬 Communication avec les Nœuds de Secours
### **Message à Envoyer aux Membre Hébergeurs**
```
Bonjour [Prénom],
Tu as été sélectionné(e) pour faire partie des **nœuds de secours de Kuby** !
Cela signifie que ton PC peut héberger une copie de Kuby en cas de panne du serveur principal.
📌 **Ce que tu dois faire** :
1. Installer Python 3.8+ sur ton PC.
2. Exécuter le script **monitor.py** en permanence (en arrière-plan).
3. **Ne pas éteindre ton PC** trop souvent (ou prévenir si tu dois le faire).
💡 **Ce qui se passe en cas de panne** :
- Si le serveur principal de Kuby crash, ton PC **démarrera automatiquement** une copie de secours.
- Tu nas **rien à faire** : tout est automatique !
- Quand le serveur principal revient, la copie de secours **séteint toute seule**.
⚠️ **Important** :
- Ton PC doit être **connecté à Internet** pour que ça fonctionne.
- Tu auras un **token Discord** dédié pour ton nœud (ne le partage pas).
- En cas de problème, contacte @OmegaKube.
Merci pour ton aide ! 🚀
```
---
## 📞 Support et Dépannage
### **Problèmes Courants**
| Problème | Cause | Solution |
|----------|-------|----------|
| `monitor.py` ne se connecte pas à GitHub | Mauvais token GitHub | Vérifier `.env` et régénérer le token. |
| Kuby ne se lance pas sur le nœud de secours | Permissions Discord manquantes | Vérifier que le bot a les permissions `Send Messages`, `Embed Links`. |
| Conflit entre deux nœuds | Problème de verrouillage | Redémarrer les deux nœuds et vérifier `kuby_status.json`. |
| `last_heartbeat` n'est pas mis à jour | Erreur de réseau | Vérifier la connexion Internet et les logs. |
### **Commandes Utiles**
```bash
# Vérifier les logs de monitor.py
python monitor.py
# Forcer la prise de relais (pour tester)
python monitor.py --force-takeover
# Mettre à jour manuellement le heartbeat
python monitor.py --update-heartbeat
```
---
## 🔮 Améliorations Futures
- [ ] **Base de données partagée** (Firebase) pour stocker les données des serveurs.
- [ ] **Système de vote** : Si plusieurs nœuds détectent un crash, ils votent pour élire le nouveau nœud actif.
- [ ] **Notifications push** (via Telegram ou email) pour les admins en cas de bascule.
- [ ] **Tableau de bord** pour surveiller l'état de tous les nœuds.
---
## 📚 Ressources
- [Documentation Disnake](https://docs.disnake.dev/)
- [GitHub Gist API](https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28)
- [Python asyncio](https://docs.python.org/3/library/asyncio.html)
---
*Documentation technique - Version 1.0 - Dernière mise à jour : 15/10/2024*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Before After
Before After

View file

@ -13,8 +13,14 @@ from typing import Optional, Dict, Any
PARIS_TZ = ZoneInfo("Europe/Paris") PARIS_TZ = ZoneInfo("Europe/Paris")
DATE_FORMAT = "%d/%m/%Y %H:%M" DATE_FORMAT = "%d/%m/%Y %H:%M"
ABSENCE_SETTINGS_FILE = "data/absence_settings.json" # Chemins ABSOLUS
ABSENCE_DATA_FILE = "data/absences.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "data")
ABSENCE_SETTINGS_FILE = os.path.join(DATA_DIR, "absence_settings.json")
ABSENCE_DATA_FILE = os.path.join(DATA_DIR, "absences.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
file_lock = asyncio.Lock() file_lock = asyncio.Lock()
def load_settings(guild_id: int) -> dict: def load_settings(guild_id: int) -> dict:

View file

@ -178,40 +178,17 @@ class BugReport(commands.Cog):
self.bot = bot self.bot = bot
async def cog_load(self): async def cog_load(self):
# Disnake appelle automatiquement cette méthode dès que la boucle d'événements est active. # ❌ DÉSACTIVÉ : Le webhook est géré par serveur_flask.py (port 5000)
self.bot.loop.create_task(self._start_webhook_server()) # Le serveur Flask proxyfie déjà les webhooks GitLab vers ce cog via /bot_event
kuby_logger.info("BugReport cog chargé (webhook géré par serveur_flask.py)")
# Créer le dossier pour REPORTS_FILE s'il n'existe pas
REPORTS_DIR = os.path.dirname(REPORTS_FILE)
os.makedirs(REPORTS_DIR, exist_ok=True)
async def _start_webhook_server(self): async def _start_webhook_server(self):
await self.bot.wait_until_ready() # ❌ FONCTION DÉSACTIVÉE (voir cog_load)
kuby_logger.warning("Tentative d'appel à _start_webhook_server() - cette fonction est désactivée")
self.web_app = web.Application()
self.web_app.router.add_post('/bot_event', self.handle_bot_event)
self.runner = web.AppRunner(self.web_app)
await self.runner.setup()
max_retries = 10
retry_delay = 5
for attempt in range(1, max_retries + 1):
try:
self.site = web.TCPSite(self.runner, '127.0.0.1', 5001)
await self.site.start()
kuby_logger.info(
f"Internal Bot Webhook Server started on 127.0.0.1:5001 (Attempt {attempt})"
)
return return
except OSError as e:
if getattr(e, 'errno', None) == 98: # Address already in use
if attempt < max_retries:
kuby_logger.warning(
f"Port 5001 already in use, retrying in {retry_delay}s... ({attempt}/{max_retries})"
)
await asyncio.sleep(retry_delay)
else:
kuby_logger.error(f"Failed to bind to port 5001 after {max_retries} attempts.")
raise e
else:
raise e
def cog_unload(self): def cog_unload(self):
if hasattr(self, 'runner'): if hasattr(self, 'runner'):

View file

@ -9,9 +9,14 @@ import logging
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
# --- CONFIGURATION --- # Chemins ABSOLUS
CONV_SETTINGS_FILE = "data/convocation_settings.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONV_BACKUPS_FILE = "data/convocation_backups.json" DATA_DIR = os.path.join(BASE_DIR, "..", "data")
CONV_SETTINGS_FILE = os.path.join(DATA_DIR, "convocation_settings.json")
CONV_BACKUPS_FILE = os.path.join(DATA_DIR, "convocation_backups.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def load_json(filepath: str) -> Dict: def load_json(filepath: str) -> Dict:
if not os.path.exists(filepath): if not os.path.exists(filepath):

View file

@ -4,13 +4,18 @@ import sqlite3
import aiohttp import aiohttp
import io import io
import os import os
import logging
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timezone from datetime import datetime, timezone
kuby_logger = logging.getLogger("KubyBot")
class Goodbye(commands.Cog): class Goodbye(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db") # Chemin absolu vers la base de données
self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
self.db_path = os.path.join(self.base_dir, "config.db")
self._init_db() self._init_db()
def _init_db(self): def _init_db(self):
@ -77,86 +82,139 @@ class Goodbye(commands.Cog):
return f"il y a {seconds} {'seconde' if seconds <= 1 else 'secondes'}" return f"il y a {seconds} {'seconde' if seconds <= 1 else 'secondes'}"
async def create_goodbye_image(self, username, avatar_url, server_name=None, banner_background=None): async def create_goodbye_image(self, username, avatar_url, server_name=None, banner_background=None):
current_dir = os.path.dirname(__file__) # Utiliser base_dir déjà calculé dans __init__
base_dir = os.path.abspath(os.path.join(current_dir, "..")) try:
# Télécharger l'avatar
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(avatar_url) as resp: async with session.get(avatar_url) as resp:
if resp.status != 200:
kuby_logger.error(f"[Goodbye] Impossible de télécharger l'avatar: {resp.status}")
raise ValueError(f"Impossible de télécharger l'avatar (code: {resp.status})")
avatar_bytes = await resp.read() avatar_bytes = await resp.read()
# Utiliser le fond personnalisé ou celui par défaut # Vérifier et utiliser le fond personnalisé ou celui par défaut
bg_path = None bg_path = None
if banner_background: if banner_background:
# On cherche dans le dossier banners/ potential_path = os.path.join(self.base_dir, "banners", banner_background)
potential_path = os.path.join(base_dir, "banners", banner_background)
if os.path.exists(potential_path): if os.path.exists(potential_path):
bg_path = potential_path bg_path = potential_path
kuby_logger.debug(f"[Goodbye] Fond personnalisé trouvé: {bg_path}")
if bg_path: if not bg_path:
default_bg = os.path.join(self.base_dir, 'background_template_goodbye.png')
if not os.path.exists(default_bg):
kuby_logger.error(f"[Goodbye] Fond par défaut introuvable: {default_bg}")
raise FileNotFoundError(f"Fond par défaut introuvable: {default_bg}")
bg_path = default_bg
kuby_logger.debug(f"[Goodbye] Utilisation du fond par défaut: {bg_path}")
# Charger le fond
background = Image.open(bg_path) background = Image.open(bg_path)
else:
background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png'))
draw = ImageDraw.Draw(background) draw = ImageDraw.Draw(background)
font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) # Vérifier et charger les polices
font_small = ImageFont.truetype(os.path.join(base_dir, 'custom_font_small.otf'), 50) font_large_path = os.path.join(self.base_dir, 'custom_font_large.otf')
font_large_omega = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 55) font_small_path = os.path.join(self.base_dir, 'custom_font_small.otf')
draw.text((325, 70), "A Bientot", font=font_large, fill=(255,255,255)) for font_path in [font_large_path, font_small_path]:
draw.text((325, 135), f"{username} chez", font=font_small, fill=(255,255,255)) if not os.path.exists(font_path):
kuby_logger.error(f"[Goodbye] Police introuvable: {font_path}")
raise FileNotFoundError(f"Police introuvable: {font_path}")
# Afficher le nom du serveur personnalisé s'il existe, sinon OMEGA KUBE font_large = ImageFont.truetype(font_large_path, 70)
font_small = ImageFont.truetype(font_small_path, 50)
# Dessiner uniquement le nom du serveur (comme pour welcome)
display_name = server_name if server_name else "OMEGA KUBE" display_name = server_name if server_name else "OMEGA KUBE"
draw.text((325, 195), display_name, font=font_large_omega, fill=(255,255,255)) draw.text((325, 135), display_name, font=font_large, fill=(255,255,255))
# Ajouter l'avatar
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS) avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
mask = Image.new('L', avatar.size, 0) mask = Image.new('L', avatar.size, 0)
mask_draw = ImageDraw.Draw(mask) mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((12, 12, 212, 212), fill=255) mask_draw.ellipse((12, 12, 212, 212), fill=255)
background.paste(avatar, (40, 40), mask) background.paste(avatar, (40, 40), mask)
# Retourner l'image
img_byte_arr = io.BytesIO() img_byte_arr = io.BytesIO()
background.save(img_byte_arr, format='PNG') background.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0) img_byte_arr.seek(0)
return img_byte_arr return img_byte_arr
except Exception as e:
kuby_logger.error(f"[Goodbye] Erreur lors de la création de l'image: {e}", exc_info=True)
return None
@commands.Cog.listener() @commands.Cog.listener()
async def on_member_remove(self, member): async def on_member_remove(self, member):
kuby_logger.info(f"[Goodbye] Départ détecté: {member.name} (ID: {member.id}) du serveur {member.guild.name}")
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background FROM server_configs WHERE server_id = ?', (member.guild.id,)) cursor.execute('SELECT goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background FROM server_configs WHERE server_id = ?', (member.guild.id,))
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
if result and result[0]: if not result or not result[0]:
kuby_logger.debug(f"[Goodbye] Aucune configuration de départ pour le serveur {member.guild.id}")
return
goodbye_channel = result[0] goodbye_channel = result[0]
server_name = result[1] server_name = result[1]
custom_message = result[2] custom_message = result[2]
banner_background = result[3] banner_background = result[3]
channel = member.guild.get_channel(goodbye_channel) channel = member.guild.get_channel(goodbye_channel)
if channel: if not channel:
kuby_logger.warning(f"[Goodbye] Salon de départ introuvable (ID: {goodbye_channel}) pour {member.guild.name}")
return
joined_message = self.humanize_time_delta(member.joined_at)
try:
img = await self.create_goodbye_image( img = await self.create_goodbye_image(
member.name, member.name,
member.avatar.url if member.avatar else member.default_avatar.url, member.avatar.url if member.avatar else member.default_avatar.url,
server_name=server_name, server_name=server_name,
banner_background=banner_background banner_background=banner_background
) )
joined_message = self.humanize_time_delta(member.joined_at)
# Utiliser le message personnalisé ou un par défaut # Envoyer avec image si disponible
description = custom_message if custom_message else "Nous espérons vous revoir bientôt <a:rain:1317973615714504806>" display_name = server_name if server_name else member.guild.name
default_message = "Nous espérons vous revoir bientôt <a:rain:1317973615714504806>"
message_text = custom_message if custom_message else default_message
components = [ if img:
disnake.ui.Container( embed = disnake.Embed(
disnake.ui.TextDisplay(f"{member} viens de nous quitter."), description=f"{member.mention} vient de nous quitter.\n\n{message_text}\n\n⌛ **Avait rejoint :** {joined_message}",
disnake.ui.Separator(divider=True), color=0x7289DA
disnake.ui.TextDisplay(description),
disnake.ui.TextDisplay(f"⌛ **Avait rejoint :** {joined_message}"),
disnake.ui.MediaGallery(disnake.MediaGalleryItem(url="attachment://goodbye.png"))
) )
] embed.set_image(url="attachment://goodbye.png")
await channel.send(components=components, file=disnake.File(img, filename="goodbye.png")) await channel.send(embed=embed, file=disnake.File(img, filename="goodbye.png"))
kuby_logger.info(f"[Goodbye] Message de départ avec image envoyé pour {member.name}")
else:
# Fallback : image non générée
kuby_logger.warning(f"[Goodbye] Image non générée pour {member.name}, envoi d'un message texte")
embed = disnake.Embed(
description=f"{member.mention} vient de nous quitter.\n\n{message_text}\n\n⌛ **Avait rejoint :** {joined_message}",
color=0x7289DA
)
await channel.send(embed=embed)
except Exception as e:
kuby_logger.error(f"[Goodbye] Erreur lors de la création ou de l'envoi du message: {e}", exc_info=True)
# Fallback avec un message minimal
try:
message_text_fb = custom_message if custom_message else "Nous espérons vous revoir bientôt"
await channel.send(f"{member.mention} vient de nous quitter. {message_text_fb}")
kuby_logger.info(f"[Goodbye] Message de fallback envoyé pour {member.name}")
except Exception as e2:
kuby_logger.error(f"[Goodbye] Échec du message de fallback: {e2}", exc_info=True)
except sqlite3.Error as e:
kuby_logger.error(f"[Goodbye] Erreur SQLite: {e}", exc_info=True)
except Exception as e:
kuby_logger.error(f"[Goodbye] Erreur inattendue dans on_member_remove: {e}", exc_info=True)
async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str: async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str:
"""Sauvegarde l'attachment et retourne le chemin du fichier""" """Sauvegarde l'attachment et retourne le chemin du fichier"""
@ -193,16 +251,22 @@ class Goodbye(commands.Cog):
@commands.slash_command(name="setgoodbye", description="Définit le salon d'au revoir et les paramètres personnalisés") @commands.slash_command(name="setgoodbye", description="Définit le salon d'au revoir et les paramètres personnalisés")
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def set_goodbye(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, server_name: str = None, message: str = None, banner_file: disnake.Attachment = None): async def set_goodbye(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, server_name: str = None, message: str = None, banner_file: disnake.Attachment = None):
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)
await interaction.response.defer(ephemeral=True)
banner_path = None banner_path = None
error_message = None
# Si un fichier est uploadé, le sauvegarder # Si un fichier est uploadé, le sauvegarder
if banner_file: if banner_file:
try: try:
banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id) banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id)
except Exception as e: except Exception as e:
await interaction.response.send_message(f"❌ Erreur avec le fichier: {e}", ephemeral=True) error_message = f"❌ Erreur avec le fichier: {e}"
return
# Si pas d'erreur, sauvegarder en base de données
if not error_message:
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background) cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background)
@ -219,13 +283,12 @@ class Goodbye(commands.Cog):
if server_name: msg += f"\nNom du serveur: {server_name}" if server_name: msg += f"\nNom du serveur: {server_name}"
if message: msg += f"\nMessage: {message}" if message: msg += f"\nMessage: {message}"
if banner_path: msg += f"\nBannière personnalisée activée." if banner_path: msg += f"\nBannière personnalisée activée."
error_message = msg
except Exception as e:
error_message = f"❌ Erreur lors de la sauvegarde en base de données: {e}"
components = [ # Envoyer la réponse finale (déferrée)
disnake.ui.Container( await interaction.followup.send(error_message, ephemeral=True)
disnake.ui.TextDisplay(msg)
)
]
await interaction.response.send_message(components=components, ephemeral=True)
def setup(bot): def setup(bot):
bot.add_cog(Goodbye(bot)) bot.add_cog(Goodbye(bot))

View file

@ -4,6 +4,7 @@ import sqlite3
import os import os
import logging import logging
from typing import Dict, Optional from typing import Dict, Optional
import asyncio
kuby_logger = logging.getLogger("KubyBot") kuby_logger = logging.getLogger("KubyBot")
@ -12,6 +13,7 @@ class Invites(commands.Cog):
self.bot = bot self.bot = bot
self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db") self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db")
self.invite_cache = {} # {guild_id: {code: uses}} self.invite_cache = {} # {guild_id: {code: uses}}
self.cache_lock = asyncio.Lock() # Verrou pour éviter les problèmes de concurrence
self._init_db() self._init_db()
def _init_db(self): def _init_db(self):
@ -38,9 +40,12 @@ class Invites(commands.Cog):
async def update_invite_cache(self, guild): async def update_invite_cache(self, guild):
try: try:
invites = await guild.invites() invites = await guild.invites()
async with self.cache_lock:
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in invites} self.invite_cache[guild.id] = {invite.code: invite.uses for invite in invites}
except disnake.Forbidden: except disnake.Forbidden:
pass kuby_logger.warning(f"[Invites] Impossible de mettre à jour le cache des invitations pour {guild.name} (permissions manquantes)")
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la mise à jour du cache des invitations pour {guild.name}: {e}")
@commands.Cog.listener() @commands.Cog.listener()
async def on_ready(self): async def on_ready(self):
@ -54,12 +59,14 @@ class Invites(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_invite_create(self, invite): async def on_invite_create(self, invite):
async with self.cache_lock:
if invite.guild.id not in self.invite_cache: if invite.guild.id not in self.invite_cache:
self.invite_cache[invite.guild.id] = {} self.invite_cache[invite.guild.id] = {}
self.invite_cache[invite.guild.id][invite.code] = invite.uses self.invite_cache[invite.guild.id][invite.code] = invite.uses
@commands.Cog.listener() @commands.Cog.listener()
async def on_invite_delete(self, invite): async def on_invite_delete(self, invite):
async with self.cache_lock:
if invite.guild.id in self.invite_cache: if invite.guild.id in self.invite_cache:
self.invite_cache[invite.guild.id].pop(invite.code, None) self.invite_cache[invite.guild.id].pop(invite.code, None)
@ -68,10 +75,14 @@ class Invites(commands.Cog):
if member.bot: return if member.bot: return
guild = member.guild guild = member.guild
old_invites = self.invite_cache.get(guild.id, {})
try: try:
old_invites = self.invite_cache.get(guild.id, {})
new_invites = await guild.invites() new_invites = await guild.invites()
except disnake.Forbidden: except disnake.Forbidden:
kuby_logger.warning(f"[Invites] Impossible de récupérer les invitations pour {guild.name} (permissions manquantes)")
return
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la récupération des invitations pour {guild.name}: {e}")
return return
used_invite = None used_invite = None
@ -86,15 +97,20 @@ class Invites(commands.Cog):
break break
# Mise à jour du cache # Mise à jour du cache
async with self.cache_lock:
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in new_invites} self.invite_cache[guild.id] = {invite.code: invite.uses for invite in new_invites}
if used_invite: if used_invite:
inviter = used_invite.inviter inviter = used_invite.inviter
if inviter: if inviter:
try:
self.record_invite(guild.id, member.id, inviter.id, used_invite.code) self.record_invite(guild.id, member.id, inviter.id, used_invite.code)
kuby_logger.info(f"📥 [Invites] {member} invité par {inviter} ({used_invite.code})") kuby_logger.info(f"📥 [Invites] {member} invité par {inviter} ({used_invite.code})")
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de l'enregistrement de l'invitation pour {member}: {e}")
# Modular join message # Modular join message
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,)) cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,))
@ -115,6 +131,9 @@ class Invites(commands.Cog):
elif used_invite.max_uses == 1: elif used_invite.max_uses == 1:
method = "invitation à usage unique" method = "invitation à usage unique"
# Récupérer les statistiques de l'inviteur
total_invites, current_invites = self.get_invite_stats(guild.id, inviter.id)
components = [ components = [
disnake.ui.Container( disnake.ui.Container(
disnake.ui.Section( disnake.ui.Section(
@ -124,12 +143,16 @@ class Invites(commands.Cog):
disnake.ui.Separator(divider=True), disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"👤 {member.mention} vient de nous rejoindre !"), disnake.ui.TextDisplay(f"👤 {member.mention} vient de nous rejoindre !"),
disnake.ui.TextDisplay(f"🤝 Grâce à {inviter.mention}"), disnake.ui.TextDisplay(f"🤝 Grâce à {inviter.mention}"),
disnake.ui.TextDisplay(f"📊 Invitations : {current_invites} membres présents / {total_invites} total"),
disnake.ui.TextDisplay(f"🔗 Via **{method}**") disnake.ui.TextDisplay(f"🔗 Via **{method}**")
) )
] ]
await log_channel.send(components=components) await log_channel.send(components=components)
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de l'envoi du message de log pour {member}: {e}")
def record_invite(self, guild_id, member_id, inviter_id, code): def record_invite(self, guild_id, member_id, inviter_id, code):
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
@ -139,16 +162,23 @@ class Invites(commands.Cog):
''', (guild_id, member_id, inviter_id, code)) ''', (guild_id, member_id, inviter_id, code))
conn.commit() conn.commit()
conn.close() conn.close()
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de l'enregistrement de l'invitation dans la base de données: {e}")
def get_inviter_info(self, guild_id, member_id): def get_inviter_info(self, guild_id, member_id):
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id)) cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id))
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
return result[0] if result else None return result[0] if result else None
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la récupération de l'inviteur pour le membre {member_id}: {e}")
return None
def get_invite_stats(self, guild_id, user_id): def get_invite_stats(self, guild_id, user_id):
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id)) cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id))
@ -162,12 +192,45 @@ class Invites(commands.Cog):
for mid in invited_ids: for mid in invited_ids:
if guild.get_member(mid): if guild.get_member(mid):
current_present += 1 current_present += 1
else:
kuby_logger.debug(f"[Invites] Membre invité {mid} non trouvé dans le serveur {guild_id}")
return len(invited_ids), current_present 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}")
return 0, 0
@commands.Cog.listener()
async def on_member_remove(self, member):
if member.bot: return
guild = member.guild
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('DELETE FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild.id, member.id))
conn.commit()
conn.close()
kuby_logger.info(f"🗑️ [Invites] Enregistrement de l'invitation supprimé pour {member.name} (ID: {member.id}) car il a quitté le serveur.")
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {member.name}: {e}")
@commands.Cog.listener()
async def on_member_ban(self, guild, user):
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('DELETE FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild.id, user.id))
conn.commit()
conn.close()
kuby_logger.info(f"🗑️ [Invites] Enregistrement de l'invitation supprimé pour {user.name} (ID: {user.id}) car il a été banni.")
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {user.name}: {e}")
@commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations") @commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations")
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel): async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel):
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
@ -184,6 +247,9 @@ class Invites(commands.Cog):
) )
] ]
await interaction.response.send_message(components=components, ephemeral=True) await interaction.response.send_message(components=components, ephemeral=True)
except Exception as e:
kuby_logger.error(f"[Invites] Erreur lors de la configuration du salon des logs d'invitations: {e}")
await interaction.response.send_message("❌ Une erreur est survenue lors de la configuration du salon des logs d'invitations.", ephemeral=True)
@commands.command(name="invitations_debug") @commands.command(name="invitations_debug")
async def invites_prefix(self, ctx, user: disnake.Member = None): async def invites_prefix(self, ctx, user: disnake.Member = None):

View file

@ -4,8 +4,14 @@ import json
import os import os
from datetime import datetime from datetime import datetime
SETTINGS_FILE = "data/security_settings.json" # Chemins ABSOLUS
WHITELIST_FILE = "data/whitelist.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "..", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
WHITELIST_FILE = os.path.join(DATA_DIR, "whitelist.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def is_whitelisted(guild_id, user_id): def is_whitelisted(guild_id, user_id):
if not os.path.exists(WHITELIST_FILE): return False if not os.path.exists(WHITELIST_FILE): return False

View file

@ -5,9 +5,14 @@ import os
from collections import deque from collections import deque
from datetime import datetime, timedelta from datetime import datetime, timedelta
# Chemins des fichiers # Chemins ABSOLUS
SETTINGS_FILE = "data/security_settings.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WHITELIST_FILE = "data/whitelist.json" DATA_DIR = os.path.join(BASE_DIR, "..", "..", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
WHITELIST_FILE = os.path.join(DATA_DIR, "whitelist.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
# Cache TTL en secondes (5 secondes pour avoir une réactivité acceptable) # Cache TTL en secondes (5 secondes pour avoir une réactivité acceptable)
CACHE_TTL = 5 CACHE_TTL = 5

View file

@ -4,7 +4,13 @@ import json
import os import os
from datetime import datetime from datetime import datetime
SETTINGS_FILE = "data/security_settings.json" # Chemins ABSOLUS
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "..", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def get_log_channel(guild, settings): def get_log_channel(guild, settings):
channel_id = settings.get("log_channel_id") channel_id = settings.get("log_channel_id")

View file

@ -4,9 +4,13 @@ import json
import os import os
from typing import Optional from typing import Optional
# Chemins des fichiers # Chemins ABSOLUS
SETTINGS_FILE = "data/security_settings.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "..", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def escape_mentions(text: str) -> str: def escape_mentions(text: str) -> str:
"""Échappe les mentions @everyone et @here pour éviter les notifications involontaires""" """Échappe les mentions @everyone et @here pour éviter les notifications involontaires"""

View file

@ -1,12 +1,17 @@
# utils/no_link.py # commandes/no_link.py
import re import re
import disnake import disnake
from disnake.ext import commands from disnake.ext import commands
import json import json
import os import os
# Fichier JSON de configuration security # Chemins ABSOLUS
SECURITY_FILE = "security.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "data")
SECURITY_FILE = os.path.join(DATA_DIR, "security.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def load_guild_security(guild_id: int) -> dict: def load_guild_security(guild_id: int) -> dict:
"""Retourne la configuration d'un serveur.""" """Retourne la configuration d'un serveur."""

View file

@ -4,9 +4,14 @@ import json
import os import os
from typing import Optional from typing import Optional
# Chemins des fichiers # Chemins ABSOLUS des fichiers (basés sur le fichier courant)
SETTINGS_FILE = "data/security_settings.json" BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WHITELIST_FILE = "data/whitelist.json" DATA_DIR = os.path.join(BASE_DIR, "..", "data")
SETTINGS_FILE = os.path.join(DATA_DIR, "security_settings.json")
WHITELIST_FILE = os.path.join(DATA_DIR, "whitelist.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
# --- UTILITAIRES --- # --- UTILITAIRES ---
def load_settings(guild_id: int) -> dict: def load_settings(guild_id: int) -> dict:
@ -31,7 +36,7 @@ def save_settings(guild_id: int, settings: dict) -> None:
data[str(guild_id)] = settings data[str(guild_id)] = settings
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w", encoding='utf-8') as f: with open(SETTINGS_FILE, "w", encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False) json.dump(data, f, indent=4, ensure_ascii=False)

View file

@ -182,7 +182,7 @@ class TicketPanelView(disnake.ui.View):
accessory=disnake.ui.Button( accessory=disnake.ui.Button(
label=cat.name, label=cat.name,
style=disnake.ButtonStyle.primary, style=disnake.ButtonStyle.primary,
custom_id=f"cat|{cat.id}" custom_id=f"catv2_{cat.id}_{int(disnake.utils.time_snowflake(disnake.utils.utcnow()) / 1000)}"
) )
) )
) )
@ -197,8 +197,13 @@ custom_id=f"cat|{cat.id}"
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Fallback: texte simple avec view legacy # Fallback: texte simple avec view legacy
cat_list = "\n".join(f"{cat.emoji} **{cat.name}** — {cat.description or 'Aucune description'}" for cat in self.config.categories[:5]) if interaction.response.is_done():
if not interaction.response.is_done(): await interaction.followup.send(
f"**🎫 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( await interaction.response.send_message(
f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:", f"**🎫 Créer un Ticket**\n\nSélectionnez une catégorie ci-dessous:",
view=CategorySelectionView(self.bot, self.config), view=CategorySelectionView(self.bot, self.config),
@ -2492,11 +2497,15 @@ class CategoryManagerView(disnake.ui.View):
async def callback(self, interaction: disnake.ModalInteraction): async def callback(self, interaction: disnake.ModalInteraction):
import uuid import uuid
cat_id = str(uuid.uuid4())[:8] cat_id = str(uuid.uuid4())[:8]
name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie")
desc_val = interaction.text_values.get("add_cat_desc", "")
emoji_val = interaction.text_values.get("add_cat_emoji", "🎫")
category = TicketCategory( category = TicketCategory(
id=cat_id, id=cat_id,
name=self.name.value, name=name_val,
description=self.description.value or "", description=desc_val,
emoji=self.emoji.value or "🎫" emoji=emoji_val
) )
self.config.categories.append(category) self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config) self.storage.save_config(interaction.guild_id, self.config)
@ -3149,11 +3158,11 @@ class TicketCommands(commands.Cog):
if not custom_id: if not custom_id:
return return
# Accept both ticket_* / cat_* / close_* custom_ids # Accept both ticket_* / catv2_* / close_* custom_ids
if not ( if not (
str(custom_id).startswith("ticket_") str(custom_id).startswith("ticket_")
or str(custom_id).startswith("cat_") or str(custom_id).startswith("catv2_")
or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel", "recruitment_close", "recruitment_accept", "recruitment_refuse"} or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"}
): ):
return return
@ -3177,24 +3186,15 @@ class TicketCommands(commands.Cog):
try: try:
# Routage manuel # Routage manuel
if custom_id == "ticket_create": # ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = catv2_{cat.id}_{snowflake}
view = TicketPanelView(self.bot, config, self.storage) if custom_id.startswith("catv2_"):
await view._create_callback(inter)
elif custom_id == "ticket_my_tickets":
view = TicketPanelView(self.bot, config, self.storage)
await view._my_tickets_callback(inter)
elif custom_id == "ticket_analytics":
view = TicketPanelView(self.bot, config, self.storage)
await view._analytics_callback(inter)
# ✅ BUGFIX: Sélection de catégorie en V2 => custom_id = cat|<catid>|<suffix>
elif str(custom_id).startswith("cat|"):
try: try:
# format: cat|<catid>|<suffix> # format: catv2_<catid>_<suffix>
parts = str(custom_id).split("|") parts = custom_id.split("_")
if len(parts) < 3: if len(parts) >= 3:
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) cat_id = "_".join(parts[1:-1])
return else:
cat_id = parts[1] if len(parts) > 1 else None
cat_id = parts[1] cat_id = parts[1]
category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) category = next((c for c in config.categories if str(c.id) == str(cat_id)), None)
@ -3232,7 +3232,7 @@ class TicketCommands(commands.Cog):
await inter.response.send_modal(modal) await inter.response.send_modal(modal)
except Exception as e: except Exception as e:
kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True)
if not inter.response.is_done(): if not inter.response.is_done():
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)

View file

@ -70,7 +70,7 @@ class TicketCategory:
self.description = description self.description = description
self.emoji = emoji self.emoji = emoji
self.color = color self.color = color
self.staff_roles = staff_roles or [] self.staff_roles = list(staff_roles) if staff_roles is not None else []
self.welcome_message = welcome_message or "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu." self.welcome_message = welcome_message or "Merci d'avoir créé un ticket. Notre équipe va vous répondre sous peu."
self.require_reason = require_reason self.require_reason = require_reason
self.max_tickets_per_user = max_tickets_per_user self.max_tickets_per_user = max_tickets_per_user

View file

@ -36,7 +36,13 @@ def log_channel_rename(channel_id: int):
timestamps.append(now) timestamps.append(now)
channel_rename_timestamps[channel_id] = timestamps channel_rename_timestamps[channel_id] = timestamps
TICKET_WHITELIST_SETTINGS_FILE = "data/ticket_whitelist_settings.json" # Chemins ABSOLUS
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "data")
TICKET_WHITELIST_SETTINGS_FILE = os.path.join(DATA_DIR, "ticket_whitelist_settings.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
def load_settings(guild_id: int) -> dict: def load_settings(guild_id: int) -> dict:
"""Charge les paramètres de ticket pour un serveur""" """Charge les paramètres de ticket pour un serveur"""

View file

@ -13,8 +13,9 @@ kuby_logger = logging.getLogger("KubyBot")
class Welcome(commands.Cog): class Welcome(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
# Base de données à la racine # Base de données à la racine - Chemin absolu
self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db") self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
self.db_path = os.path.join(self.base_dir, "config.db")
self._init_db() self._init_db()
def _init_db(self): def _init_db(self):
@ -56,16 +57,18 @@ class Welcome(commands.Cog):
conn.commit() conn.commit()
conn.close() conn.close()
async def create_welcome_image(self, username, avatar_url, server_name=None, banner_background=None): async def create_welcome_image(self, username, avatar_url, server_name=None, banner_background=None, custom_message=None):
current_dir = os.path.dirname(__file__) # Utiliser base_dir déjà calculé dans __init__
base_dir = os.path.abspath(os.path.join(current_dir, ".."))
try: try:
# Télécharger l'avatar
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(avatar_url) as resp: async with session.get(avatar_url) as resp:
if resp.status != 200:
kuby_logger.error(f"[Welcome] Impossible de télécharger l'avatar: {resp.status}")
raise ValueError(f"Impossible de télécharger l'avatar (code: {resp.status})")
avatar_bytes = await resp.read() avatar_bytes = await resp.read()
# Utiliser le fond personnalisé ou celui par défaut # Vérifier et utiliser le fond personnalisé ou celui par défaut
bg_path = None bg_path = None
if banner_background: if banner_background:
# Si c'est un chemin absolu (pour compatibilité temporaire) # Si c'est un chemin absolu (pour compatibilité temporaire)
@ -73,40 +76,90 @@ class Welcome(commands.Cog):
bg_path = banner_background bg_path = banner_background
else: else:
# Sinon on cherche dans le dossier banners/ # Sinon on cherche dans le dossier banners/
potential_path = os.path.join(base_dir, "banners", banner_background) potential_path = os.path.join(self.base_dir, "banners", banner_background)
if os.path.exists(potential_path): if os.path.exists(potential_path):
bg_path = potential_path bg_path = potential_path
kuby_logger.debug(f"[Welcome] Fond personnalisé trouvé: {bg_path}")
if bg_path: if not bg_path:
default_bg = os.path.join(self.base_dir, 'background_template.png')
if not os.path.exists(default_bg):
kuby_logger.error(f"[Welcome] Fond par défaut introuvable: {default_bg}")
raise FileNotFoundError(f"Fond par défaut introuvable: {default_bg}")
bg_path = default_bg
kuby_logger.debug(f"[Welcome] Utilisation du fond par défaut: {bg_path}")
# Charger le fond
background = Image.open(bg_path) background = Image.open(bg_path)
else:
background = Image.open(os.path.join(base_dir, 'background_template.png'))
draw = ImageDraw.Draw(background) draw = ImageDraw.Draw(background)
font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) # Charger les polices custom (comme sur EXEMPLE_ARRIVER.png)
font_small = ImageFont.truetype(os.path.join(base_dir, 'custom_font_small.otf'), 50) font_large_path = os.path.join(self.base_dir, 'custom_font_large.otf')
font_small_path = os.path.join(self.base_dir, 'custom_font_small.otf')
draw.text((325, 70), "Bienvenue", font=font_large, fill=(255,255,255)) # Les 3 lignes de texte à afficher (format identique à l'exemple)
draw.text((325, 135), f"{username}", font=font_small, fill=(255,255,255)) line1_text = "BIENVENUE"
line2_text = username.upper()
line3_text = (server_name or "").upper()
# Afficher le nom du serveur uniquement si configuré # Zone de texte disponible (à droite de l'avatar)
if server_name: text_area_left = 280
font_server = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 55) text_area_right = background.width - 20
draw.text((325, 195), server_name, font=font_server, fill=(255,255,255)) text_area_width = text_area_right - text_area_left
text_area_center_x = text_area_left + text_area_width // 2
# Fonction pour trouver la taille de police optimale
def fit_font(font_path, text, max_size, max_width):
size = max_size
while size > 10:
font = ImageFont.truetype(font_path, size)
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
h = bbox[3] - bbox[1]
return font, w, h
size -= 2
font = ImageFont.truetype(font_path, 10)
bbox = draw.textbbox((0, 0), text, font=font)
return font, bbox[2] - bbox[0], bbox[3] - bbox[1]
# Adapter chaque ligne à la zone disponible
font1, w1, h1 = fit_font(font_large_path, line1_text, 65, text_area_width)
font2, w2, h2 = fit_font(font_small_path, line2_text, 35, text_area_width)
font3, w3, h3 = fit_font(font_large_path, line3_text, 65, text_area_width)
# Espacement entre les lignes
spacing_1_2 = 15 # entre BIENVENUE et username
spacing_2_3 = 20 # entre username et nom du serveur
total_height = h1 + spacing_1_2 + h2 + spacing_2_3 + h3
# Centrer verticalement le bloc de 3 lignes
start_y = (background.height - total_height) // 2
# Dessiner chaque ligne centrée horizontalement dans la zone
y = start_y
draw.text((text_area_center_x - w1 // 2, y), line1_text, font=font1, fill=(255, 255, 255))
y += h1 + spacing_1_2
draw.text((text_area_center_x - w2 // 2, y), line2_text, font=font2, fill=(255, 255, 255))
y += h2 + spacing_2_3
draw.text((text_area_center_x - w3 // 2, y), line3_text, font=font3, fill=(255, 255, 255))
# Ajouter l'avatar
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS) avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
mask = Image.new('L', avatar.size, 0) mask = Image.new('L', avatar.size, 0)
mask_draw = ImageDraw.Draw(mask) mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((12, 12, 212, 212), fill=255) mask_draw.ellipse((12, 12, 212, 212), fill=255)
background.paste(avatar, (40, 40), mask) background.paste(avatar, (40, 40), mask)
# Retourner l'image
img_byte_arr = io.BytesIO() img_byte_arr = io.BytesIO()
background.save(img_byte_arr, format='PNG') background.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0) img_byte_arr.seek(0)
return img_byte_arr return img_byte_arr
except Exception as e: except Exception as e:
logging.error(f"Erreur image: {e}") kuby_logger.error(f"[Welcome] Erreur lors de la création de l'image: {e}", exc_info=True)
return None return None
@commands.Cog.listener() @commands.Cog.listener()
@ -121,7 +174,7 @@ class Welcome(commands.Cog):
conn.close() conn.close()
if not result: if not result:
kuby_logger.warning(f"⚠️ [Welcome] Aucune configuration trouvée dans la base de données pour le serveur {member.guild.id}") kuby_logger.debug(f"⚠️ [Welcome] Aucune configuration trouvée dans la base de données pour le serveur {member.guild.id}")
return return
welcome_channel = result[0] welcome_channel = result[0]
@ -170,40 +223,76 @@ class Welcome(commands.Cog):
if welcome_channel: if welcome_channel:
channel = member.guild.get_channel(welcome_channel) channel = member.guild.get_channel(welcome_channel)
if channel: if channel:
try:
# Créer l'image de bienvenue
server_display_name = server_name if server_name else member.guild.name
img = await self.create_welcome_image( img = await self.create_welcome_image(
member.name, member.name,
member.avatar.url if member.avatar else member.default_avatar.url, member.avatar.url if member.avatar else member.default_avatar.url,
server_name=server_name, server_name=server_display_name,
banner_background=banner_background banner_background=banner_background,
custom_message=welcome_channel_message
) )
if img:
# Attendre un peu que le Cog d'invitations traite le join # Attendre un peu que le Cog d'invitations traite le join
await asyncio.sleep(1) await asyncio.sleep(1)
# Récupérer l'inviteur
invites_cog = self.bot.get_cog("Invites") invites_cog = self.bot.get_cog("Invites")
inviter_text = "" inviter_text = ""
if invites_cog: if invites_cog:
try:
inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id) inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id)
if inviter_id: if inviter_id:
inviter = member.guild.get_member(inviter_id) or await self.bot.fetch_user(inviter_id) inviter = member.guild.get_member(inviter_id)
if not inviter:
try:
inviter = await self.bot.fetch_user(inviter_id)
except (disnake.NotFound, disnake.HTTPException) as e:
kuby_logger.warning(f"[Welcome] Impossible de récupérer l'inviteur {inviter_id}: {e}")
inviter = None
if inviter:
total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id) total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id)
inviter_text = f"\n\n**Invité par :** {inviter.mention} ({current} invitations)" inviter_text = f"\n\n**Invité par :** {inviter.mention} ({current} invitations)"
else:
inviter_text = "\n\n**Invité par :** [Utilisateur introuvable]"
except Exception as e:
kuby_logger.error(f"[Welcome] Erreur lors de la récupération de l'inviteur: {e}", exc_info=True)
inviter_text = ""
# Utiliser le message personnalisé ou un message par défaut # Envoyer le message avec le format exact requis
channel_description = (welcome_channel_message.format( if img:
username=member.name, server_display_name = server_name if server_name else member.guild.name
servername=member.guild.name, # IMPORTANT: transmettre aussi le nom réel du serveur à Pillow pour les placeholders {server}
member_count=member.guild.member_count # (utile quand welcome_server_name n'est pas configuré)
) if welcome_channel_message else "Bienvenue dans le serveur !\nSi vous cherchez des RolePlay de qualité,\nVous êtes au bon endroit. <a:sip:1316891821858619452>") + inviter_text # (server_name est utilisé par create_welcome_image)
embed = disnake.Embed(
components = [ description=f"Bienvenue sur {server_display_name} {member.name} ! Grâce à toi, nous avons atteint les {member.guild.member_count} membres !{inviter_text}",
disnake.ui.Container( color=0x9B59B6
disnake.ui.TextDisplay(f"Merci d'accueillir {member} 🤝"),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(channel_description),
disnake.ui.MediaGallery(disnake.MediaGalleryItem(url="attachment://welcome.png"))
) )
]
await channel.send(content=member.mention, components=components, file=disnake.File(img, filename="welcome.png")) embed.set_image(url="attachment://welcome.png")
await channel.send(embed=embed, file=disnake.File(img, filename="welcome.png"))
kuby_logger.info(f"[Welcome] Message de bienvenue avec image envoyé pour {member.name}")
else:
# Fallback : image non générée, envoyer un message texte
kuby_logger.warning(f"[Welcome] Image non générée pour {member.name}, envoi d'un message texte")
server_display_name = server_name if server_name else member.guild.name
await channel.send(f"Bienvenue sur {server_display_name} {member.mention} ! Grâce à toi, nous avons atteint les {member.guild.member_count} membres !{inviter_text}")
except Exception as e:
kuby_logger.error(f"[Welcome] Erreur lors de l'envoi du message dans le salon: {e}", exc_info=True)
# Fallback avec un message minimal
try:
await channel.send(f"{member.mention} Bienvenue dans le serveur !")
kuby_logger.info(f"[Welcome] Message de fallback envoyé pour {member.name}")
except Exception as e2:
kuby_logger.error(f"[Welcome] Échec du message de fallback: {e2}", exc_info=True)
else:
kuby_logger.warning(f"[Welcome] Salon de bienvenue introuvable (ID: {welcome_channel}) pour {member.guild.name}")
else:
kuby_logger.debug(f"[Welcome] Aucun salon de bienvenue configuré pour {member.guild.name}")
async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str: async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str:
"""Sauvegarde l'attachment et retourne le chemin du fichier""" """Sauvegarde l'attachment et retourne le chemin du fichier"""
@ -240,16 +329,22 @@ class Welcome(commands.Cog):
@commands.slash_command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière") @commands.slash_command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière")
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None): async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None):
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)
await interaction.response.defer(ephemeral=True)
banner_path = None banner_path = None
error_message = None
# Si un fichier est uploadé, le sauvegarder # Si un fichier est uploadé, le sauvegarder
if banner_file: if banner_file:
try: try:
banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id) banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id)
except Exception as e: except Exception as e:
await interaction.response.send_message(f"❌ Erreur avec le fichier: {e}", ephemeral=True) error_message = f"❌ Erreur avec le fichier: {e}"
return
# Si pas d'erreur, sauvegarder en base de données
if not error_message:
try:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message) cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message)
@ -264,7 +359,12 @@ class Welcome(commands.Cog):
conn.close() conn.close()
bg_used = "background_template.png" if not banner_path else (banner_path.split('/')[-1]) bg_used = "background_template.png" if not banner_path else (banner_path.split('/')[-1])
await interaction.response.send_message(f"✅ Configuration de bienvenida enregistrée !\nFond utilisé: {bg_used}", ephemeral=True) error_message = f"✅ Configuration de bienvenue enregistrée !\nFond utilisé: {bg_used}"
except Exception as e:
error_message = f"❌ Erreur lors de la sauvegarde en base de données: {e}"
# Envoyer la réponse finale (déferrée)
await interaction.followup.send(error_message, ephemeral=True)
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations") @commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None): async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):

View file

@ -4,7 +4,13 @@ import json
import os import os
from typing import List from typing import List
WHITELIST_FILE = "data/whitelist.json" # Chemins ABSOLUS des fichiers
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "..", "data")
WHITELIST_FILE = os.path.join(DATA_DIR, "whitelist.json")
# Créer le dossier data/ s'il n'existe pas
os.makedirs(DATA_DIR, exist_ok=True)
class Whitelist(commands.Cog): class Whitelist(commands.Cog):
def __init__(self, bot): def __init__(self, bot):

View file

@ -29,7 +29,7 @@ def send_discord_log(status, message, color, author=None, commit_msg=None):
payload = {"embeds": [embed]} payload = {"embeds": [embed]}
try: try:
requests.post(DISCORD_LOG_URL, json=payload, timeout=5) requests.post(DISCORD_LOG_URL, json=payload, timeout=10) # ✅ Augmenté de 5s à 10s
except Exception as e: except Exception as e:
print(f"[!] Erreur envoi Discord : {e}") print(f"[!] Erreur envoi Discord : {e}")

BIN
test_welcome_default_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
test_welcome_final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
test_welcome_fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

BIN
test_welcome_output.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
test_welcome_real.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB