diff --git a/EXEMPLE_ARRIVER.png b/EXEMPLE_ARRIVER.png new file mode 100644 index 0000000..092c725 Binary files /dev/null and b/EXEMPLE_ARRIVER.png differ diff --git a/EXPLICATION_NON_DEV.md b/EXPLICATION_NON_DEV.md new file mode 100644 index 0000000..ebaf8c1 --- /dev/null +++ b/EXPLICATION_NON_DEV.md @@ -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.* diff --git a/Explication.md b/Explication.md new file mode 100644 index 0000000..df937de --- /dev/null +++ b/Explication.md @@ -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* diff --git a/banners/banner_1369669999345537145.png b/banners/banner_1369669999345537145.png index cddf163..a007141 100644 Binary files a/banners/banner_1369669999345537145.png and b/banners/banner_1369669999345537145.png differ diff --git a/commandes/absence_staff.py b/commandes/absence_staff.py index b3e30fe..79c7259 100755 --- a/commandes/absence_staff.py +++ b/commandes/absence_staff.py @@ -13,8 +13,14 @@ from typing import Optional, Dict, Any PARIS_TZ = ZoneInfo("Europe/Paris") DATE_FORMAT = "%d/%m/%Y %H:%M" -ABSENCE_SETTINGS_FILE = "data/absence_settings.json" -ABSENCE_DATA_FILE = "data/absences.json" +# Chemins ABSOLUS +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() def load_settings(guild_id: int) -> dict: diff --git a/commandes/bug_report.py b/commandes/bug_report.py index d72647e..392e09d 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -178,40 +178,17 @@ class BugReport(commands.Cog): self.bot = bot async def cog_load(self): - # Disnake appelle automatiquement cette mĂ©thode dĂšs que la boucle d'Ă©vĂ©nements est active. - self.bot.loop.create_task(self._start_webhook_server()) + # ❌ DÉSACTIVÉ : Le webhook est gĂ©rĂ© par serveur_flask.py (port 5000) + # 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): - await self.bot.wait_until_ready() - - self.web_app = web.Application() - self.web_app.router.add_post('/bot_event', self.handle_bot_event) - self.runner = web.AppRunner(self.web_app) - await self.runner.setup() - - max_retries = 10 - retry_delay = 5 - - for attempt in range(1, max_retries + 1): - try: - self.site = web.TCPSite(self.runner, '127.0.0.1', 5001) - await self.site.start() - kuby_logger.info( - f"Internal Bot Webhook Server started on 127.0.0.1:5001 (Attempt {attempt})" - ) - return - 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 + # ❌ FONCTION DÉSACTIVÉE (voir cog_load) + kuby_logger.warning("Tentative d'appel Ă  _start_webhook_server() - cette fonction est dĂ©sactivĂ©e") + return def cog_unload(self): if hasattr(self, 'runner'): diff --git a/commandes/convocation.py b/commandes/convocation.py index d8bc255..e88ae1a 100755 --- a/commandes/convocation.py +++ b/commandes/convocation.py @@ -9,9 +9,14 @@ import logging kuby_logger = logging.getLogger("KubyBot") -# --- CONFIGURATION --- -CONV_SETTINGS_FILE = "data/convocation_settings.json" -CONV_BACKUPS_FILE = "data/convocation_backups.json" +# Chemins ABSOLUS +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +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: if not os.path.exists(filepath): diff --git a/commandes/goodbye.py b/commandes/goodbye.py index f906036..dceffca 100644 --- a/commandes/goodbye.py +++ b/commandes/goodbye.py @@ -4,13 +4,18 @@ import sqlite3 import aiohttp import io import os +import logging from PIL import Image, ImageDraw, ImageFont from datetime import datetime, timezone +kuby_logger = logging.getLogger("KubyBot") + class Goodbye(commands.Cog): def __init__(self, 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() def _init_db(self): @@ -77,86 +82,139 @@ class Goodbye(commands.Cog): 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): - current_dir = os.path.dirname(__file__) - base_dir = os.path.abspath(os.path.join(current_dir, "..")) - - async with aiohttp.ClientSession() as session: - async with session.get(avatar_url) as resp: - avatar_bytes = await resp.read() + # Utiliser base_dir dĂ©jĂ  calculĂ© dans __init__ + try: + # TĂ©lĂ©charger l'avatar + async with aiohttp.ClientSession() as session: + async with session.get(avatar_url) as resp: + if resp.status != 200: + kuby_logger.error(f"[Goodbye] Impossible de tĂ©lĂ©charger l'avatar: {resp.status}") + raise ValueError(f"Impossible de tĂ©lĂ©charger l'avatar (code: {resp.status})") + avatar_bytes = await resp.read() - # Utiliser le fond personnalisĂ© ou celui par dĂ©faut - bg_path = None - if banner_background: - # On cherche dans le dossier banners/ - potential_path = os.path.join(base_dir, "banners", banner_background) - if os.path.exists(potential_path): - bg_path = potential_path - - if bg_path: + # VĂ©rifier et utiliser le fond personnalisĂ© ou celui par dĂ©faut + bg_path = None + if banner_background: + potential_path = os.path.join(self.base_dir, "banners", banner_background) + if os.path.exists(potential_path): + bg_path = potential_path + kuby_logger.debug(f"[Goodbye] Fond personnalisĂ© trouvĂ©: {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) - else: - background = Image.open(os.path.join(base_dir, 'background_template_goodbye.png')) - draw = ImageDraw.Draw(background) - - font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) - 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) + draw = ImageDraw.Draw(background) + + # VĂ©rifier et charger les polices + 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') + + 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)) - draw.text((325, 135), f"{username} chez", font=font_small, fill=(255,255,255)) - - # Afficher le nom du serveur personnalisĂ© s'il existe, sinon OMEGA KUBE - display_name = server_name if server_name else "OMEGA KUBE" - draw.text((325, 195), display_name, font=font_large_omega, fill=(255,255,255)) + # Ajouter l'avatar + avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS) + mask = Image.new('L', avatar.size, 0) + mask_draw = ImageDraw.Draw(mask) + mask_draw.ellipse((12, 12, 212, 212), fill=255) + background.paste(avatar, (40, 40), mask) - avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS) - mask = Image.new('L', avatar.size, 0) - mask_draw = ImageDraw.Draw(mask) - mask_draw.ellipse((12, 12, 212, 212), fill=255) - background.paste(avatar, (40, 40), mask) - - img_byte_arr = io.BytesIO() - background.save(img_byte_arr, format='PNG') - img_byte_arr.seek(0) - return img_byte_arr + # Retourner l'image + img_byte_arr = io.BytesIO() + background.save(img_byte_arr, format='PNG') + img_byte_arr.seek(0) + return img_byte_arr + + except Exception as e: + kuby_logger.error(f"[Goodbye] Erreur lors de la crĂ©ation de l'image: {e}", exc_info=True) + return None @commands.Cog.listener() async def on_member_remove(self, member): - conn = sqlite3.connect(self.db_path) - 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() + kuby_logger.info(f"[Goodbye] DĂ©part dĂ©tectĂ©: {member.name} (ID: {member.id}) du serveur {member.guild.name}") + + try: + conn = sqlite3.connect(self.db_path) + 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] server_name = result[1] custom_message = result[2] banner_background = result[3] 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( member.name, member.avatar.url if member.avatar else member.default_avatar.url, server_name=server_name, banner_background=banner_background ) - joined_message = self.humanize_time_delta(member.joined_at) - # Utiliser le message personnalisĂ© ou un par dĂ©faut - description = custom_message if custom_message else "Nous espĂ©rons vous revoir bientĂŽt " + # Envoyer avec image si disponible + display_name = server_name if server_name else member.guild.name + default_message = "Nous espĂ©rons vous revoir bientĂŽt " + message_text = custom_message if custom_message else default_message - components = [ - disnake.ui.Container( - disnake.ui.TextDisplay(f"{member} viens de nous quitter."), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay(description), - disnake.ui.TextDisplay(f"⌛ **Avait rejoint :** {joined_message}"), - disnake.ui.MediaGallery(disnake.MediaGalleryItem(url="attachment://goodbye.png")) + if img: + 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(components=components, file=disnake.File(img, filename="goodbye.png")) + embed.set_image(url="attachment://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: """Sauvegarde l'attachment et retourne le chemin du fichier""" @@ -190,42 +248,47 @@ class Goodbye(commands.Cog): 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) 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 + error_message = None # Si un fichier est uploadĂ©, le sauvegarder if banner_file: try: banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id) except Exception as e: - await interaction.response.send_message(f"❌ Erreur avec le fichier: {e}", ephemeral=True) - 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() + error_message = f"❌ Erreur avec le fichier: {e}" - msg = f"✅ Salon d'aurevoir 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." + # Si pas d'erreur, sauvegarder en base de donnĂ©es + if not error_message: + try: + 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 = [ - disnake.ui.Container( - disnake.ui.TextDisplay(msg) - ) - ] - await interaction.response.send_message(components=components, ephemeral=True) + # Envoyer la rĂ©ponse finale (dĂ©ferrĂ©e) + await interaction.followup.send(error_message, ephemeral=True) def setup(bot): bot.add_cog(Goodbye(bot)) \ No newline at end of file diff --git a/commandes/invites.py b/commandes/invites.py index cfe320b..1d45448 100644 --- a/commandes/invites.py +++ b/commandes/invites.py @@ -4,6 +4,7 @@ import sqlite3 import os import logging from typing import Dict, Optional +import asyncio kuby_logger = logging.getLogger("KubyBot") @@ -12,6 +13,7 @@ class Invites(commands.Cog): self.bot = bot self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db") self.invite_cache = {} # {guild_id: {code: uses}} + self.cache_lock = asyncio.Lock() # Verrou pour Ă©viter les problĂšmes de concurrence self._init_db() def _init_db(self): @@ -38,9 +40,12 @@ class Invites(commands.Cog): async def update_invite_cache(self, guild): try: 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: - 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() async def on_ready(self): @@ -54,24 +59,30 @@ class Invites(commands.Cog): @commands.Cog.listener() async def on_invite_create(self, invite): - if invite.guild.id not in self.invite_cache: - self.invite_cache[invite.guild.id] = {} - self.invite_cache[invite.guild.id][invite.code] = invite.uses + async with self.cache_lock: + if invite.guild.id not in self.invite_cache: + self.invite_cache[invite.guild.id] = {} + self.invite_cache[invite.guild.id][invite.code] = invite.uses @commands.Cog.listener() async def on_invite_delete(self, invite): - if invite.guild.id in self.invite_cache: - self.invite_cache[invite.guild.id].pop(invite.code, None) + async with self.cache_lock: + if invite.guild.id in self.invite_cache: + self.invite_cache[invite.guild.id].pop(invite.code, None) @commands.Cog.listener() async def on_member_join(self, member): if member.bot: return guild = member.guild - old_invites = self.invite_cache.get(guild.id, {}) try: + old_invites = self.invite_cache.get(guild.id, {}) new_invites = await guild.invites() 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 used_invite = None @@ -86,104 +97,159 @@ class Invites(commands.Cog): break # 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: inviter = used_invite.inviter if inviter: - self.record_invite(guild.id, member.id, inviter.id, used_invite.code) - kuby_logger.info(f"đŸ“„ [Invites] {member} invitĂ© par {inviter} ({used_invite.code})") + try: + 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 - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,)) - result = cursor.fetchone() - conn.close() - - if result: - log_channel = guild.get_channel(result[0]) - if log_channel: - # Determine method - method = "invitation standard" - if guild.vanity_url_code and used_invite.code == guild.vanity_url_code: - method = "lien personnalisĂ©" - elif used_invite.max_age == 0: - method = "invitation permanente" - elif used_invite.temporary: - method = "invitation temporaire" - elif used_invite.max_uses == 1: - method = "invitation Ă  usage unique" - - components = [ - disnake.ui.Container( - disnake.ui.Section( - f"Nouveau membre via invitation", - accessory=disnake.ui.Thumbnail(member.display_avatar.url) - ), - disnake.ui.Separator(divider=True), - disnake.ui.TextDisplay(f"đŸ‘€ {member.mention} vient de nous rejoindre !"), - disnake.ui.TextDisplay(f"đŸ€ GrĂące Ă  {inviter.mention}"), - disnake.ui.TextDisplay(f"🔗 Via **{method}**") - ) - ] - await log_channel.send(components=components) + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,)) + result = cursor.fetchone() + conn.close() + + if result: + log_channel = guild.get_channel(result[0]) + if log_channel: + # Determine method + method = "invitation standard" + if guild.vanity_url_code and used_invite.code == guild.vanity_url_code: + method = "lien personnalisĂ©" + elif used_invite.max_age == 0: + method = "invitation permanente" + elif used_invite.temporary: + method = "invitation temporaire" + elif used_invite.max_uses == 1: + method = "invitation Ă  usage unique" + + # RĂ©cupĂ©rer les statistiques de l'inviteur + total_invites, current_invites = self.get_invite_stats(guild.id, inviter.id) + + components = [ + disnake.ui.Container( + disnake.ui.Section( + f"Nouveau membre via invitation", + accessory=disnake.ui.Thumbnail(member.display_avatar.url) + ), + disnake.ui.Separator(divider=True), + disnake.ui.TextDisplay(f"đŸ‘€ {member.mention} vient de nous rejoindre !"), + 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): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO member_invites (guild_id, member_id, inviter_id, invite_code) - VALUES (?, ?, ?, ?) - ON CONFLICT(guild_id, member_id) DO UPDATE SET inviter_id=excluded.inviter_id, invite_code=excluded.invite_code - ''', (guild_id, member_id, inviter_id, code)) - conn.commit() - conn.close() + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO member_invites (guild_id, member_id, inviter_id, invite_code) + VALUES (?, ?, ?, ?) + ON CONFLICT(guild_id, member_id) DO UPDATE SET inviter_id=excluded.inviter_id, invite_code=excluded.invite_code + ''', (guild_id, member_id, inviter_id, code)) + 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): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id)) - result = cursor.fetchone() - conn.close() - return result[0] if result else None + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id)) + result = cursor.fetchone() + 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): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id)) - invited_ids = [row[0] for row in cursor.fetchall()] - conn.close() + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id)) + invited_ids = [row[0] for row in cursor.fetchall()] + conn.close() - guild = self.bot.get_guild(guild_id) - if not guild: return 0, 0 + guild = self.bot.get_guild(guild_id) + if not guild: return 0, 0 - current_present = 0 - for mid in invited_ids: - if guild.get_member(mid): - current_present += 1 + current_present = 0 + for mid in invited_ids: + if guild.get_member(mid): + 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.has_permissions(administrator=True) async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO invite_configs (guild_id, log_channel_id) - VALUES (?, ?) - ON CONFLICT(guild_id) DO UPDATE SET log_channel_id=excluded.log_channel_id - ''', (interaction.guild_id, channel.id)) - conn.commit() - conn.close() - - components = [ - 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) + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO invite_configs (guild_id, log_channel_id) + VALUES (?, ?) + ON CONFLICT(guild_id) DO UPDATE SET log_channel_id=excluded.log_channel_id + ''', (interaction.guild_id, channel.id)) + conn.commit() + conn.close() + + components = [ + 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) + 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") async def invites_prefix(self, ctx, user: disnake.Member = None): diff --git a/commandes/modules_security/antiraid.py b/commandes/modules_security/antiraid.py index 0244034..a04d9f4 100644 --- a/commandes/modules_security/antiraid.py +++ b/commandes/modules_security/antiraid.py @@ -4,8 +4,14 @@ import json import os from datetime import datetime -SETTINGS_FILE = "data/security_settings.json" -WHITELIST_FILE = "data/whitelist.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") +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): if not os.path.exists(WHITELIST_FILE): return False diff --git a/commandes/modules_security/antispam.py b/commandes/modules_security/antispam.py index 19c8969..c26f9a9 100644 --- a/commandes/modules_security/antispam.py +++ b/commandes/modules_security/antispam.py @@ -5,9 +5,14 @@ import os from collections import deque from datetime import datetime, timedelta -# Chemins des fichiers -SETTINGS_FILE = "data/security_settings.json" -WHITELIST_FILE = "data/whitelist.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") +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 = 5 diff --git a/commandes/modules_security/logs_manager.py b/commandes/modules_security/logs_manager.py index ce97861..fda1eef 100644 --- a/commandes/modules_security/logs_manager.py +++ b/commandes/modules_security/logs_manager.py @@ -4,7 +4,13 @@ import json import os 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): channel_id = settings.get("log_channel_id") diff --git a/commandes/modules_security/rules.py b/commandes/modules_security/rules.py index 05f0766..bf1ac7d 100644 --- a/commandes/modules_security/rules.py +++ b/commandes/modules_security/rules.py @@ -4,9 +4,13 @@ import json import os from typing import Optional -# Chemins des fichiers -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 escape_mentions(text: str) -> str: """Échappe les mentions @everyone et @here pour Ă©viter les notifications involontaires""" diff --git a/commandes/no_link.py b/commandes/no_link.py index 3ce67b6..9e9e8de 100755 --- a/commandes/no_link.py +++ b/commandes/no_link.py @@ -1,12 +1,17 @@ -# utils/no_link.py +# commandes/no_link.py import re import disnake from disnake.ext import commands import json import os -# Fichier JSON de configuration security -SECURITY_FILE = "security.json" +# Chemins ABSOLUS +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: """Retourne la configuration d'un serveur.""" diff --git a/commandes/security.py b/commandes/security.py index 4e3909c..3a2416a 100755 --- a/commandes/security.py +++ b/commandes/security.py @@ -4,9 +4,14 @@ import json import os from typing import Optional -# Chemins des fichiers -SETTINGS_FILE = "data/security_settings.json" -WHITELIST_FILE = "data/whitelist.json" +# Chemins ABSOLUS des fichiers (basĂ©s sur le fichier courant) +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) # --- UTILITAIRES --- 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 - 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: json.dump(data, f, indent=4, ensure_ascii=False) diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index 9fba48c..0d416a8 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -182,7 +182,7 @@ class TicketPanelView(disnake.ui.View): accessory=disnake.ui.Button( label=cat.name, 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 traceback.print_exc() # 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 not interaction.response.is_done(): + if 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( f"**đŸŽ« CrĂ©er un Ticket**\n\nSĂ©lectionnez une catĂ©gorie ci-dessous:", view=CategorySelectionView(self.bot, self.config), @@ -2492,11 +2497,15 @@ class CategoryManagerView(disnake.ui.View): async def callback(self, interaction: disnake.ModalInteraction): import uuid 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( id=cat_id, - name=self.name.value, - description=self.description.value or "", - emoji=self.emoji.value or "đŸŽ«" + name=name_val, + description=desc_val, + emoji=emoji_val ) self.config.categories.append(category) self.storage.save_config(interaction.guild_id, self.config) @@ -3149,11 +3158,11 @@ class TicketCommands(commands.Cog): if not custom_id: return - # Accept both ticket_* / cat_* / close_* custom_ids + # Accept both ticket_* / catv2_* / close_* custom_ids if not ( str(custom_id).startswith("ticket_") - or str(custom_id).startswith("cat_") - or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel", "recruitment_close", "recruitment_accept", "recruitment_refuse"} + or str(custom_id).startswith("catv2_") + or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"} ): return @@ -3177,24 +3186,15 @@ class TicketCommands(commands.Cog): try: # Routage manuel - if custom_id == "ticket_create": - view = TicketPanelView(self.bot, config, self.storage) - 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|| - elif str(custom_id).startswith("cat|"): + # ✅ BUGFIX: SĂ©lection de catĂ©gorie en V2 => custom_id = catv2_{cat.id}_{snowflake} + if custom_id.startswith("catv2_"): try: - # format: cat|| - parts = str(custom_id).split("|") - if len(parts) < 3: - await inter.response.send_message("CatĂ©gorie non trouvĂ©e.", ephemeral=True) - return + # format: catv2__ + parts = custom_id.split("_") + if len(parts) >= 3: + cat_id = "_".join(parts[1:-1]) + else: + cat_id = parts[1] if len(parts) > 1 else None cat_id = parts[1] category = next((c for c in config.categories if str(c.id) == str(cat_id)), None) @@ -3232,7 +3232,7 @@ class TicketCommands(commands.Cog): await inter.response.send_modal(modal) except Exception as e: - kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) + kuby_logger.error(f"[TicketSystem] catv2_ routing error: {e}", exc_info=True) if not inter.response.is_done(): await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index f1fd33a..7381f93 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -70,7 +70,7 @@ class TicketCategory: self.description = description self.emoji = emoji 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.require_reason = require_reason self.max_tickets_per_user = max_tickets_per_user diff --git a/commandes/ticket_whitelist.py b/commandes/ticket_whitelist.py index ac106e1..e065ee9 100644 --- a/commandes/ticket_whitelist.py +++ b/commandes/ticket_whitelist.py @@ -36,7 +36,13 @@ def log_channel_rename(channel_id: int): timestamps.append(now) 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: """Charge les paramĂštres de ticket pour un serveur""" diff --git a/commandes/welcome.py b/commandes/welcome.py index 476bfcc..2721ff3 100644 --- a/commandes/welcome.py +++ b/commandes/welcome.py @@ -13,8 +13,9 @@ kuby_logger = logging.getLogger("KubyBot") class Welcome(commands.Cog): def __init__(self, bot): self.bot = bot - # Base de donnĂ©es Ă  la racine - self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db") + # Base de donnĂ©es Ă  la racine - Chemin absolu + 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() def _init_db(self): @@ -56,16 +57,18 @@ class Welcome(commands.Cog): conn.commit() conn.close() - async def create_welcome_image(self, username, avatar_url, server_name=None, banner_background=None): - current_dir = os.path.dirname(__file__) - base_dir = os.path.abspath(os.path.join(current_dir, "..")) - + async def create_welcome_image(self, username, avatar_url, server_name=None, banner_background=None, custom_message=None): + # Utiliser base_dir dĂ©jĂ  calculĂ© dans __init__ try: + # TĂ©lĂ©charger l'avatar async with aiohttp.ClientSession() as session: 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() - # Utiliser le fond personnalisĂ© ou celui par dĂ©faut + # VĂ©rifier et utiliser le fond personnalisĂ© ou celui par dĂ©faut bg_path = None if banner_background: # Si c'est un chemin absolu (pour compatibilitĂ© temporaire) @@ -73,40 +76,90 @@ class Welcome(commands.Cog): bg_path = banner_background else: # 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): bg_path = potential_path + kuby_logger.debug(f"[Welcome] Fond personnalisĂ© trouvĂ©: {bg_path}") - if bg_path: - background = Image.open(bg_path) - else: - background = Image.open(os.path.join(base_dir, 'background_template.png')) + if not bg_path: + default_bg = os.path.join(self.base_dir, 'background_template.png') + if not os.path.exists(default_bg): + kuby_logger.error(f"[Welcome] Fond par dĂ©faut introuvable: {default_bg}") + raise FileNotFoundError(f"Fond par dĂ©faut introuvable: {default_bg}") + bg_path = default_bg + kuby_logger.debug(f"[Welcome] Utilisation du fond par dĂ©faut: {bg_path}") + # Charger le fond + background = Image.open(bg_path) draw = ImageDraw.Draw(background) - font_large = ImageFont.truetype(os.path.join(base_dir, 'custom_font_large.otf'), 70) - font_small = ImageFont.truetype(os.path.join(base_dir, 'custom_font_small.otf'), 50) + # Charger les polices custom (comme sur EXEMPLE_ARRIVER.png) + 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) mask = Image.new('L', avatar.size, 0) mask_draw = ImageDraw.Draw(mask) mask_draw.ellipse((12, 12, 212, 212), fill=255) background.paste(avatar, (40, 40), mask) + # Retourner l'image img_byte_arr = io.BytesIO() background.save(img_byte_arr, format='PNG') img_byte_arr.seek(0) return img_byte_arr + 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 @commands.Cog.listener() @@ -121,7 +174,7 @@ class Welcome(commands.Cog): conn.close() 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 welcome_channel = result[0] @@ -170,40 +223,76 @@ class Welcome(commands.Cog): if welcome_channel: channel = member.guild.get_channel(welcome_channel) if channel: - img = await self.create_welcome_image( - member.name, - member.avatar.url if member.avatar else member.default_avatar.url, - server_name=server_name, - banner_background=banner_background - ) - if img: + try: + # CrĂ©er l'image de bienvenue + server_display_name = server_name if server_name else member.guild.name + img = await self.create_welcome_image( + member.name, + member.avatar.url if member.avatar else member.default_avatar.url, + 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 await asyncio.sleep(1) + + # RĂ©cupĂ©rer l'inviteur invites_cog = self.bot.get_cog("Invites") inviter_text = "" if invites_cog: - inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id) - if inviter_id: - inviter = member.guild.get_member(inviter_id) or await self.bot.fetch_user(inviter_id) - total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id) - inviter_text = f"\n\n**InvitĂ© par :** {inviter.mention} ({current} invitations)" + try: + inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id) + if inviter_id: + inviter = member.guild.get_member(inviter_id) + if not inviter: + try: + inviter = await self.bot.fetch_user(inviter_id) + except (disnake.NotFound, disnake.HTTPException) as e: + kuby_logger.warning(f"[Welcome] Impossible de rĂ©cupĂ©rer l'inviteur {inviter_id}: {e}") + inviter = None + + if inviter: + total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id) + 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 - channel_description = (welcome_channel_message.format( - username=member.name, - servername=member.guild.name, - member_count=member.guild.member_count - ) if welcome_channel_message else "Bienvenue dans le serveur !\nSi vous cherchez des RolePlay de qualitĂ©,\nVous ĂȘtes au bon endroit. ") + inviter_text - - components = [ - disnake.ui.Container( - 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")) + # Envoyer le message avec le format exact requis + if img: + server_display_name = server_name if server_name else member.guild.name + # IMPORTANT: transmettre aussi le nom rĂ©el du serveur Ă  Pillow pour les placeholders {server} + # (utile quand welcome_server_name n'est pas configurĂ©) + # (server_name est utilisĂ© par create_welcome_image) + embed = disnake.Embed( + description=f"Bienvenue sur {server_display_name} {member.name} ! GrĂące Ă  toi, nous avons atteint les {member.guild.member_count} membres !{inviter_text}", + color=0x9B59B6 ) - ] - 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: """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.has_permissions(administrator=True) async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None): + # ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images) + await interaction.response.defer(ephemeral=True) + banner_path = None + error_message = None # Si un fichier est uploadĂ©, le sauvegarder if banner_file: try: banner_path = await self.save_banner_attachment(banner_file, interaction.guild_id) except Exception as e: - await interaction.response.send_message(f"❌ Erreur avec le fichier: {e}", ephemeral=True) - return + error_message = f"❌ Erreur avec le fichier: {e}" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message) - VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(server_id) - DO UPDATE SET welcome_channel=excluded.welcome_channel, - welcome_dm_message=excluded.welcome_dm_message, - welcome_server_name=excluded.welcome_server_name, - welcome_banner_background=COALESCE(excluded.welcome_banner_background, welcome_banner_background), - welcome_channel_message=excluded.welcome_channel_message''', - (interaction.guild_id, channel.id, message_dm, server_name, banner_path, message_salon)) - conn.commit() - conn.close() + # Si pas d'erreur, sauvegarder en base de donnĂ©es + if not error_message: + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('''INSERT INTO server_configs (server_id, welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(server_id) + DO UPDATE SET welcome_channel=excluded.welcome_channel, + welcome_dm_message=excluded.welcome_dm_message, + welcome_server_name=excluded.welcome_server_name, + welcome_banner_background=COALESCE(excluded.welcome_banner_background, welcome_banner_background), + 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]) - await interaction.response.send_message(f"✅ Configuration de bienvenida enregistrĂ©e !\nFond utilisĂ©: {bg_used}", ephemeral=True) + # Envoyer la rĂ©ponse finale (dĂ©ferrĂ©e) + await interaction.followup.send(error_message, ephemeral=True) @commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations") async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None): diff --git a/commandes/whitelist.py b/commandes/whitelist.py index 5479a76..d21048e 100755 --- a/commandes/whitelist.py +++ b/commandes/whitelist.py @@ -4,7 +4,13 @@ import json import os 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): def __init__(self, bot): diff --git a/serveur_flask.py b/serveur_flask.py index 564ba76..470dc14 100644 --- a/serveur_flask.py +++ b/serveur_flask.py @@ -29,7 +29,7 @@ def send_discord_log(status, message, color, author=None, commit_msg=None): payload = {"embeds": [embed]} try: - requests.post(DISCORD_LOG_URL, json=payload, timeout=5) + requests.post(DISCORD_LOG_URL, json=payload, timeout=10) # ✅ AugmentĂ© de 5s Ă  10s except Exception as e: print(f"[!] Erreur envoi Discord : {e}") diff --git a/test_welcome_default_bg.png b/test_welcome_default_bg.png new file mode 100644 index 0000000..2fdae12 Binary files /dev/null and b/test_welcome_default_bg.png differ diff --git a/test_welcome_final.png b/test_welcome_final.png new file mode 100644 index 0000000..f332f8e Binary files /dev/null and b/test_welcome_final.png differ diff --git a/test_welcome_fixed.png b/test_welcome_fixed.png new file mode 100644 index 0000000..62a3656 Binary files /dev/null and b/test_welcome_fixed.png differ diff --git a/test_welcome_output.png b/test_welcome_output.png new file mode 100644 index 0000000..7d573ae Binary files /dev/null and b/test_welcome_output.png differ diff --git a/test_welcome_real.png b/test_welcome_real.png new file mode 100644 index 0000000..5075af0 Binary files /dev/null and b/test_welcome_real.png differ