Merge branch 'dev' into 'main'
Dev See merge request Omega_Kube/kuby!25
BIN
EXEMPLE_ARRIVER.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
319
EXPLICATION_NON_DEV.md
Normal 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)*
|
||||||
|
- C’est un **fichier partagé** (comme un tableau de bord) qui contient **l’état de santé de Kuby** :
|
||||||
|
- Est-ce qu’il 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). C’est **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 n’est reçu pendant 10 minutes**, c’est qu’il 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 d’Alice 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 qu’ils 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 qu’ils ne doivent PAS faire** :
|
||||||
|
- **Modifier le code** (risque de casser le système).
|
||||||
|
- **Éteindre leur PC sans prévenir** (si c’est un nœud critique).
|
||||||
|
- **Partager leur token Discord** (c’est 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 d’un numéro en danger).
|
||||||
|
- **Exemple de message qu’ils reçoivent** :
|
||||||
|
```
|
||||||
|
⚠️ **Kuby a rencontré un problème, mais ne t’inquiète pas !**
|
||||||
|
Un nœud de secours a pris le relais.
|
||||||
|
✅ Le service continue de fonctionner normalement.
|
||||||
|
💬 Besoin d’aide ? 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 l’installation (très important !).
|
||||||
|
- Cliquer sur **Install Now**.
|
||||||
|
|
||||||
|
2. **Télécharger les fichiers** que je t’enverrai (un dossier zip).
|
||||||
|
|
||||||
|
3. **Ouvrir l’invite 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
|
||||||
|
```
|
||||||
|
*(C’est 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 l’enverrai 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 l’invite de commandes** dans le dossier où sont les fichiers.
|
||||||
|
2. **Lancer le programme de surveillance** :
|
||||||
|
```bash
|
||||||
|
python monitor.py
|
||||||
|
```
|
||||||
|
*(C’est 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 c’est 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 d’Alice) **détecte le problème**.
|
||||||
|
2. **Action** : Le PC d’Alice **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 qu’un 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 d’Alice) 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**.
|
||||||
|
- **C’est pour ça qu’il 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 qu’un onglet Chrome ouvert** ou qu’un 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 m’organiser).
|
||||||
|
- **Plus il y a de nœuds**, moins c’est grave si un PC est éteint.
|
||||||
|
- Si tu es **souvent absent**, je peux te retirer de la liste et trouver quelqu’un d’autre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **❓ Que faire si mon Internet tombe en panne ?**
|
||||||
|
➡️ **Rien**. Quand ton Internet reviendra, le programme **reprendra automatiquement** son travail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **❓ Est-ce que c’est 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** : c’est 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 d’erreur 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 n’est pas obligatoire.)
|
||||||
|
- Un **PC classique** suffit largement.
|
||||||
|
- Si tu as un **Raspberry Pi**, c’est encore mieux (il consomme moins d’électricité).
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **Pourquoi c’est 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 qu’un 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, c’est la Machine. Ton VPS, c’est 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 n’as rien eu à faire.*
|
||||||
|
> **C’est aussi simple que ça.** »
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 **Métaphore Finale**
|
||||||
|
|
||||||
|
| **Dans *Person of Interest*** | **Dans notre système** |
|
||||||
|
|--------------------------------|-----------------------|
|
||||||
|
| La Machine **ne dort jamais** | Kuby **ne s’arrê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 d’aider à 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
|
|
@ -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 n’as **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*
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 195 KiB |
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
return
|
||||||
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
|
|
||||||
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'):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
avatar_bytes = await resp.read()
|
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()
|
||||||
|
|
||||||
# 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:
|
draw = ImageDraw.Draw(background)
|
||||||
background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png'))
|
|
||||||
draw = ImageDraw.Draw(background)
|
# Vérifier et charger les polices
|
||||||
|
font_large_path = os.path.join(self.base_dir, 'custom_font_large.otf')
|
||||||
font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70)
|
font_small_path = os.path.join(self.base_dir, 'custom_font_small.otf')
|
||||||
font_small = ImageFont.truetype(os.path.join(base_dir, 'custom_font_small.otf'), 50)
|
|
||||||
font_large_omega = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 55)
|
for font_path in [font_large_path, font_small_path]:
|
||||||
|
if not os.path.exists(font_path):
|
||||||
|
kuby_logger.error(f"[Goodbye] Police introuvable: {font_path}")
|
||||||
|
raise FileNotFoundError(f"Police introuvable: {font_path}")
|
||||||
|
|
||||||
|
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"
|
||||||
|
draw.text((325, 135), display_name, font=font_large, fill=(255,255,255))
|
||||||
|
|
||||||
draw.text((325, 70), "A Bientot", font=font_large, fill=(255,255,255))
|
# Ajouter l'avatar
|
||||||
draw.text((325, 135), f"{username} chez", font=font_small, fill=(255,255,255))
|
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
|
||||||
|
mask = Image.new('L', avatar.size, 0)
|
||||||
# Afficher le nom du serveur personnalisé s'il existe, sinon OMEGA KUBE
|
mask_draw = ImageDraw.Draw(mask)
|
||||||
display_name = server_name if server_name else "OMEGA KUBE"
|
mask_draw.ellipse((12, 12, 212, 212), fill=255)
|
||||||
draw.text((325, 195), display_name, font=font_large_omega, fill=(255,255,255))
|
background.paste(avatar, (40, 40), mask)
|
||||||
|
|
||||||
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
|
# Retourner l'image
|
||||||
mask = Image.new('L', avatar.size, 0)
|
img_byte_arr = io.BytesIO()
|
||||||
mask_draw = ImageDraw.Draw(mask)
|
background.save(img_byte_arr, format='PNG')
|
||||||
mask_draw.ellipse((12, 12, 212, 212), fill=255)
|
img_byte_arr.seek(0)
|
||||||
background.paste(avatar, (40, 40), mask)
|
return img_byte_arr
|
||||||
|
|
||||||
img_byte_arr = io.BytesIO()
|
except Exception as e:
|
||||||
background.save(img_byte_arr, format='PNG')
|
kuby_logger.error(f"[Goodbye] Erreur lors de la création de l'image: {e}", exc_info=True)
|
||||||
img_byte_arr.seek(0)
|
return None
|
||||||
return img_byte_arr
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_member_remove(self, member):
|
async def on_member_remove(self, member):
|
||||||
conn = sqlite3.connect(self.db_path)
|
kuby_logger.info(f"[Goodbye] Départ détecté: {member.name} (ID: {member.id}) du serveur {member.guild.name}")
|
||||||
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,))
|
try:
|
||||||
result = cursor.fetchone()
|
conn = sqlite3.connect(self.db_path)
|
||||||
conn.close()
|
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,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not result or not result[0]:
|
||||||
|
kuby_logger.debug(f"[Goodbye] Aucune configuration de départ pour le serveur {member.guild.id}")
|
||||||
|
return
|
||||||
|
|
||||||
if result and result[0]:
|
|
||||||
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"""
|
||||||
|
|
@ -190,42 +248,47 @@ class Goodbye(commands.Cog):
|
||||||
|
|
||||||
return os.path.basename(banner_path)
|
return os.path.basename(banner_path)
|
||||||
|
|
||||||
@commands.slash_command(name="setgoodbye", description="Définit le salon d'aurevoir 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
|
|
||||||
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background)
|
|
||||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id)
|
|
||||||
DO UPDATE SET goodbye_channel=excluded.goodbye_channel,
|
|
||||||
goodbye_server_name=COALESCE(excluded.goodbye_server_name, goodbye_server_name),
|
|
||||||
goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message),
|
|
||||||
goodbye_banner_background=COALESCE(excluded.goodbye_banner_background, goodbye_banner_background)''',
|
|
||||||
(interaction.guild_id, channel.id, server_name, message, banner_path))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
msg = f"✅ Salon d'aurevoir défini sur {channel.mention}"
|
# Si pas d'erreur, sauvegarder en base de données
|
||||||
if server_name: msg += f"\nNom du serveur: {server_name}"
|
if not error_message:
|
||||||
if message: msg += f"\nMessage: {message}"
|
try:
|
||||||
if banner_path: msg += f"\nBannière personnalisée activée."
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel, goodbye_server_name, goodbye_message, goodbye_banner_background)
|
||||||
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id)
|
||||||
|
DO UPDATE SET goodbye_channel=excluded.goodbye_channel,
|
||||||
|
goodbye_server_name=COALESCE(excluded.goodbye_server_name, goodbye_server_name),
|
||||||
|
goodbye_message=COALESCE(excluded.goodbye_message, goodbye_message),
|
||||||
|
goodbye_banner_background=COALESCE(excluded.goodbye_banner_background, goodbye_banner_background)''',
|
||||||
|
(interaction.guild_id, channel.id, server_name, message, banner_path))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
msg = f"✅ Salon d'au revoir défini sur {channel.mention}"
|
||||||
|
if server_name: msg += f"\nNom du serveur: {server_name}"
|
||||||
|
if message: msg += f"\nMessage: {message}"
|
||||||
|
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))
|
||||||
|
|
@ -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()
|
||||||
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in invites}
|
async with self.cache_lock:
|
||||||
|
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,24 +59,30 @@ class Invites(commands.Cog):
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_invite_create(self, invite):
|
async def on_invite_create(self, invite):
|
||||||
if invite.guild.id not in self.invite_cache:
|
async with self.cache_lock:
|
||||||
self.invite_cache[invite.guild.id] = {}
|
if invite.guild.id not in self.invite_cache:
|
||||||
self.invite_cache[invite.guild.id][invite.code] = invite.uses
|
self.invite_cache[invite.guild.id] = {}
|
||||||
|
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):
|
||||||
if invite.guild.id in self.invite_cache:
|
async with self.cache_lock:
|
||||||
self.invite_cache[invite.guild.id].pop(invite.code, None)
|
if invite.guild.id in self.invite_cache:
|
||||||
|
self.invite_cache[invite.guild.id].pop(invite.code, None)
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_member_join(self, member):
|
async def on_member_join(self, member):
|
||||||
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,104 +97,159 @@ class Invites(commands.Cog):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Mise à jour du cache
|
# Mise à jour du cache
|
||||||
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in new_invites}
|
async with self.cache_lock:
|
||||||
|
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:
|
||||||
self.record_invite(guild.id, member.id, inviter.id, used_invite.code)
|
try:
|
||||||
kuby_logger.info(f"📥 [Invites] {member} invité par {inviter} ({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})")
|
||||||
|
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
|
||||||
conn = sqlite3.connect(self.db_path)
|
try:
|
||||||
cursor = conn.cursor()
|
conn = sqlite3.connect(self.db_path)
|
||||||
cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,))
|
cursor = conn.cursor()
|
||||||
result = cursor.fetchone()
|
cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,))
|
||||||
conn.close()
|
result = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
if result:
|
|
||||||
log_channel = guild.get_channel(result[0])
|
if result:
|
||||||
if log_channel:
|
log_channel = guild.get_channel(result[0])
|
||||||
# Determine method
|
if log_channel:
|
||||||
method = "invitation standard"
|
# Determine method
|
||||||
if guild.vanity_url_code and used_invite.code == guild.vanity_url_code:
|
method = "invitation standard"
|
||||||
method = "lien personnalisé"
|
if guild.vanity_url_code and used_invite.code == guild.vanity_url_code:
|
||||||
elif used_invite.max_age == 0:
|
method = "lien personnalisé"
|
||||||
method = "invitation permanente"
|
elif used_invite.max_age == 0:
|
||||||
elif used_invite.temporary:
|
method = "invitation permanente"
|
||||||
method = "invitation temporaire"
|
elif used_invite.temporary:
|
||||||
elif used_invite.max_uses == 1:
|
method = "invitation temporaire"
|
||||||
method = "invitation à usage unique"
|
elif used_invite.max_uses == 1:
|
||||||
|
method = "invitation à usage unique"
|
||||||
components = [
|
|
||||||
disnake.ui.Container(
|
# Récupérer les statistiques de l'inviteur
|
||||||
disnake.ui.Section(
|
total_invites, current_invites = self.get_invite_stats(guild.id, inviter.id)
|
||||||
f"Nouveau membre via invitation",
|
|
||||||
accessory=disnake.ui.Thumbnail(member.display_avatar.url)
|
components = [
|
||||||
),
|
disnake.ui.Container(
|
||||||
disnake.ui.Separator(divider=True),
|
disnake.ui.Section(
|
||||||
disnake.ui.TextDisplay(f"👤 {member.mention} vient de nous rejoindre !"),
|
f"Nouveau membre via invitation",
|
||||||
disnake.ui.TextDisplay(f"🤝 Grâce à {inviter.mention}"),
|
accessory=disnake.ui.Thumbnail(member.display_avatar.url)
|
||||||
disnake.ui.TextDisplay(f"🔗 Via **{method}**")
|
),
|
||||||
)
|
disnake.ui.Separator(divider=True),
|
||||||
]
|
disnake.ui.TextDisplay(f"👤 {member.mention} vient de nous rejoindre !"),
|
||||||
await log_channel.send(components=components)
|
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}**")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
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):
|
||||||
conn = sqlite3.connect(self.db_path)
|
try:
|
||||||
cursor = conn.cursor()
|
conn = sqlite3.connect(self.db_path)
|
||||||
cursor.execute('''
|
cursor = conn.cursor()
|
||||||
INSERT INTO member_invites (guild_id, member_id, inviter_id, invite_code)
|
cursor.execute('''
|
||||||
VALUES (?, ?, ?, ?)
|
INSERT INTO member_invites (guild_id, member_id, inviter_id, invite_code)
|
||||||
ON CONFLICT(guild_id, member_id) DO UPDATE SET inviter_id=excluded.inviter_id, invite_code=excluded.invite_code
|
VALUES (?, ?, ?, ?)
|
||||||
''', (guild_id, member_id, inviter_id, code))
|
ON CONFLICT(guild_id, member_id) DO UPDATE SET inviter_id=excluded.inviter_id, invite_code=excluded.invite_code
|
||||||
conn.commit()
|
''', (guild_id, member_id, inviter_id, code))
|
||||||
conn.close()
|
conn.commit()
|
||||||
|
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):
|
||||||
conn = sqlite3.connect(self.db_path)
|
try:
|
||||||
cursor = conn.cursor()
|
conn = sqlite3.connect(self.db_path)
|
||||||
cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id))
|
cursor = conn.cursor()
|
||||||
result = cursor.fetchone()
|
cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id))
|
||||||
conn.close()
|
result = cursor.fetchone()
|
||||||
return result[0] if result else None
|
conn.close()
|
||||||
|
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):
|
||||||
conn = sqlite3.connect(self.db_path)
|
try:
|
||||||
cursor = conn.cursor()
|
conn = sqlite3.connect(self.db_path)
|
||||||
cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id))
|
cursor = conn.cursor()
|
||||||
invited_ids = [row[0] for row in cursor.fetchall()]
|
cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id))
|
||||||
conn.close()
|
invited_ids = [row[0] for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
guild = self.bot.get_guild(guild_id)
|
guild = self.bot.get_guild(guild_id)
|
||||||
if not guild: return 0, 0
|
if not guild: return 0, 0
|
||||||
|
|
||||||
current_present = 0
|
current_present = 0
|
||||||
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
|
||||||
|
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
|
||||||
|
|
||||||
return len(invited_ids), current_present
|
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):
|
||||||
conn = sqlite3.connect(self.db_path)
|
try:
|
||||||
cursor = conn.cursor()
|
conn = sqlite3.connect(self.db_path)
|
||||||
cursor.execute('''
|
cursor = conn.cursor()
|
||||||
INSERT INTO invite_configs (guild_id, log_channel_id)
|
cursor.execute('''
|
||||||
VALUES (?, ?)
|
INSERT INTO invite_configs (guild_id, log_channel_id)
|
||||||
ON CONFLICT(guild_id) DO UPDATE SET log_channel_id=excluded.log_channel_id
|
VALUES (?, ?)
|
||||||
''', (interaction.guild_id, channel.id))
|
ON CONFLICT(guild_id) DO UPDATE SET log_channel_id=excluded.log_channel_id
|
||||||
conn.commit()
|
''', (interaction.guild_id, channel.id))
|
||||||
conn.close()
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
components = [
|
|
||||||
disnake.ui.Container(
|
components = [
|
||||||
disnake.ui.TextDisplay(f"✅ Salon des logs d'invitations défini sur {channel.mention}")
|
disnake.ui.Container(
|
||||||
)
|
disnake.ui.TextDisplay(f"✅ Salon des logs d'invitations défini sur {channel.mention}")
|
||||||
]
|
)
|
||||||
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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
background = Image.open(bg_path)
|
default_bg = os.path.join(self.base_dir, 'background_template.png')
|
||||||
else:
|
if not os.path.exists(default_bg):
|
||||||
background = Image.open(os.path.join(base_dir, 'background_template.png'))
|
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)
|
||||||
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')
|
||||||
|
|
||||||
|
# Les 3 lignes de texte à afficher (format identique à l'exemple)
|
||||||
|
line1_text = "BIENVENUE"
|
||||||
|
line2_text = username.upper()
|
||||||
|
line3_text = (server_name or "").upper()
|
||||||
|
|
||||||
|
# Zone de texte disponible (à droite de l'avatar)
|
||||||
|
text_area_left = 280
|
||||||
|
text_area_right = background.width - 20
|
||||||
|
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))
|
||||||
|
|
||||||
draw.text((325, 70), "Bienvenue", font=font_large, fill=(255,255,255))
|
|
||||||
draw.text((325, 135), f"{username}", font=font_small, fill=(255,255,255))
|
|
||||||
|
|
||||||
# Afficher le nom du serveur uniquement si configuré
|
|
||||||
if server_name:
|
|
||||||
font_server = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 55)
|
|
||||||
draw.text((325, 195), server_name, font=font_server, 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:
|
||||||
img = await self.create_welcome_image(
|
try:
|
||||||
member.name,
|
# Créer l'image de bienvenue
|
||||||
member.avatar.url if member.avatar else member.default_avatar.url,
|
server_display_name = server_name if server_name else member.guild.name
|
||||||
server_name=server_name,
|
img = await self.create_welcome_image(
|
||||||
banner_background=banner_background
|
member.name,
|
||||||
)
|
member.avatar.url if member.avatar else member.default_avatar.url,
|
||||||
if img:
|
server_name=server_display_name,
|
||||||
|
banner_background=banner_background,
|
||||||
|
custom_message=welcome_channel_message
|
||||||
|
)
|
||||||
|
|
||||||
# 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:
|
||||||
inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id)
|
try:
|
||||||
if inviter_id:
|
inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id)
|
||||||
inviter = member.guild.get_member(inviter_id) or await self.bot.fetch_user(inviter_id)
|
if inviter_id:
|
||||||
total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id)
|
inviter = member.guild.get_member(inviter_id)
|
||||||
inviter_text = f"\n\n**Invité par :** {inviter.mention} ({current} invitations)"
|
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)
|
||||||
|
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,31 +329,42 @@ 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
|
|
||||||
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
# Si pas d'erreur, sauvegarder en base de données
|
||||||
cursor = conn.cursor()
|
if not error_message:
|
||||||
cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message)
|
try:
|
||||||
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(server_id)
|
conn = sqlite3.connect(self.db_path)
|
||||||
DO UPDATE SET welcome_channel=excluded.welcome_channel,
|
cursor = conn.cursor()
|
||||||
welcome_dm_message=excluded.welcome_dm_message,
|
cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message)
|
||||||
welcome_server_name=excluded.welcome_server_name,
|
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(server_id)
|
||||||
welcome_banner_background=COALESCE(excluded.welcome_banner_background, welcome_banner_background),
|
DO UPDATE SET welcome_channel=excluded.welcome_channel,
|
||||||
welcome_channel_message=excluded.welcome_channel_message''',
|
welcome_dm_message=excluded.welcome_dm_message,
|
||||||
(interaction.guild_id, channel.id, message_dm, server_name, banner_path, message_salon))
|
welcome_server_name=excluded.welcome_server_name,
|
||||||
conn.commit()
|
welcome_banner_background=COALESCE(excluded.welcome_banner_background, welcome_banner_background),
|
||||||
conn.close()
|
welcome_channel_message=excluded.welcome_channel_message''',
|
||||||
|
(interaction.guild_id, channel.id, message_dm, server_name, banner_path, message_salon))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
bg_used = "background_template.png" if not banner_path else (banner_path.split('/')[-1])
|
||||||
|
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}"
|
||||||
|
|
||||||
bg_used = "background_template.png" if not banner_path else (banner_path.split('/')[-1])
|
# Envoyer la réponse finale (déferrée)
|
||||||
await interaction.response.send_message(f"✅ Configuration de bienvenida enregistrée !\nFond utilisé: {bg_used}", ephemeral=True)
|
await interaction.followup.send(error_message, ephemeral=True)
|
||||||
|
|
||||||
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
|
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
|
||||||
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
|
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
After Width: | Height: | Size: 40 KiB |
BIN
test_welcome_final.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
test_welcome_fixed.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
test_welcome_output.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
test_welcome_real.png
Normal file
|
After Width: | Height: | Size: 195 KiB |